<?php

/*
 * 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\Form;

use TYPO3\CMS\Backend\Form\Element\AvatarElement;
use TYPO3\CMS\Core\Utility\GeneralUtility;

/**
 * Create an element object depending on renderType.
 *
 * This is the main factory to instantiate any node within the render
 * chain of FormEngine. All nodes must implement NodeInterface.
 *
 * Nodes are "container" classes of the render chain, "element" classes that
 * render single elements, as well as "fieldWizard", "fieldInformation" and
 * "fieldControl" classes which are called by single elements to enrich them.
 *
 * This factory gets a string "renderType" and then looks up in a list which
 * specific class should handle this renderType. This list can be extended with
 * own renderTypes by extensions, existing renderTypes can be overridden, and
 * - for complex cases - it is possible to register own resolver classes for single
 * renderTypes that can return a node class name to override the default lookup list.
 *
 * @todo: Declare final in v13. May require adding an interface to allow mocking in tests
 */
class NodeFactory
{
    /**
     * Node resolver classes
     * Nested array with nodeName as key, (sorted) priority as sub key and class as value
     */
    private array $nodeResolver;

    /**
     * Default registry of node name to handling class
     */
    private array $nodeTypes = [
        // Default container classes
        'flex' => Container\FlexFormEntryContainer::class,
        'flexFormContainerContainer' => Container\FlexFormContainerContainer::class,
        'flexFormElementContainer' => Container\FlexFormElementContainer::class,
        'flexFormNoTabsContainer' => Container\FlexFormNoTabsContainer::class,
        'flexFormSectionContainer' => Container\FlexFormSectionContainer::class,
        'flexFormTabsContainer' => Container\FlexFormTabsContainer::class,
        'fullRecordContainer' => Container\FullRecordContainer::class,
        'inline' => Container\InlineControlContainer::class,
        'inlineRecordContainer' => Container\InlineRecordContainer::class,
        'file' => Container\FilesControlContainer::class,
        'fileReferenceContainer' => Container\FileReferenceContainer::class,
        'siteLanguage' => Container\SiteLanguageContainer::class,
        'listOfFieldsContainer' => Container\ListOfFieldsContainer::class,
        'noTabsContainer' => Container\NoTabsContainer::class,
        'formWrapContainer' => Container\FormWrapContainer::class,
        'paletteAndSingleContainer' => Container\PaletteAndSingleContainer::class,
        'singleFieldContainer' => Container\SingleFieldContainer::class,
        'tabsContainer' => Container\TabsContainer::class,

        // Default single element classes
        'check' => Element\CheckboxElement::class,
        'checkboxToggle' => Element\CheckboxToggleElement::class,
        'checkboxLabeledToggle' => Element\CheckboxLabeledToggleElement::class,
        'email' => Element\EmailElement::class,
        'group' => Element\GroupElement::class,
        'folder' => Element\FolderElement::class,
        'input' => Element\InputTextElement::class,
        'number' => Element\NumberElement::class,
        'datetime' => Element\DatetimeElement::class,
        'link' => Element\LinkElement::class,
        'password' => Element\PasswordElement::class,
        'hidden' => Element\InputHiddenElement::class,
        'imageManipulation' => Element\ImageManipulationElement::class,
        'none' => Element\NoneElement::class,
        'radio' => Element\RadioElement::class,
        'selectCheckBox' => Element\SelectCheckBoxElement::class,
        'selectMultipleSideBySide' => Element\SelectMultipleSideBySideElement::class,
        'selectTree' => Element\SelectTreeElement::class,
        'selectSingle' => Element\SelectSingleElement::class,
        'selectSingleBox' => Element\SelectSingleBoxElement::class,
        'color' => Element\ColorElement::class,
        'codeEditor' => Element\CodeEditorElement::class,
        'text' => Element\TextElement::class,
        'textTable' => Element\TextTableElement::class,
        'unknown' => Element\UnknownElement::class,
        'user' => Element\UserElement::class,
        // special renderType for type="user" on sys_file_storage is_public column
        'userSysFileStorageIsPublic' => Element\UserSysFileStorageIsPublicElement::class,
        'fileInfo' => Element\FileInfoElement::class,
        'mfaInfo' => Element\MfaInfoElement::class,
        'slug' => Element\InputSlugElement::class,
        'language' => Element\SelectSingleElement::class,
        'country' => Element\SelectCountryElement::class,
        'category' => Element\CategoryElement::class,
        'passthrough' => Element\PassThroughElement::class,
        'belayoutwizard' => Element\BackendLayoutWizardElement::class,
        'json' => Element\JsonElement::class,
        'uuid' => Element\UuidElement::class,
        'tablePermission' => Element\TablePermissionElement::class,
        // special renderTypes for user settings
        'avatar' => AvatarElement::class,

        // Default classes to enrich single elements
        'fieldControl' => NodeExpansion\FieldControl::class,
        'fieldInformation' => NodeExpansion\FieldInformation::class,
        'fieldWizard' => NodeExpansion\FieldWizard::class,

        // Element information
        'adminIsSystemMaintainer' => FieldInformation\AdminIsSystemMaintainer::class,
        'backendLayoutFromParentPage' => FieldInformation\BackendLayoutFromParentPage::class,
        'noSelectableItemsAvailable' => FieldInformation\NoSelectableItemsAvailable::class,

        // Element wizards
        'defaultLanguageDifferences' => FieldWizard\DefaultLanguageDifferences::class,
        'localizationStateSelector' => FieldWizard\LocalizationStateSelector::class,
        'otherLanguageContent' => FieldWizard\OtherLanguageContent::class,
        'otherLanguageThumbnails' => FieldWizard\OtherLanguageThumbnails::class,
        'recordsOverview' => FieldWizard\RecordsOverview::class,
        'selectIcons' => FieldWizard\SelectIcons::class,
        'shortcutValidation' => FieldWizard\ShortcutValidation::class,
        'tableList' => FieldWizard\TableList::class,

        // Element controls
        'addRecord' => FieldControl\AddRecord::class,
        'editPopup' => FieldControl\EditPopup::class,
        'elementBrowser' => FieldControl\ElementBrowser::class,
        'insertClipboard' => FieldControl\InsertClipboard::class,
        'linkPopup' => FieldControl\LinkPopup::class,
        'listModule' => FieldControl\ListModule::class,
        'resetSelection' => FieldControl\ResetSelection::class,
        'passwordGenerator' => FieldControl\PasswordGenerator::class,
    ];

    public function __construct()
    {
        $this->nodeTypes = $this->getAdditionalNodeTypesFromConfiguration($this->nodeTypes);
        $this->nodeResolver = $this->getRegisteredNodeResolvers();
    }

    /**
     * Create a node depending on type
     *
     * @param array $data All information to decide which class should be instantiated and given down to sub nodes
     * @throws Exception
     */
    public function create(array $data): NodeInterface
    {
        if (empty($data['renderType'])) {
            throw new Exception(
                'Missing "renderType" in TCA of field "[' . ($data['tableName'] ?? 'unknown') . '][' . ($data['fieldName'] ?? 'unknown') . ']".',
                1431452406
            );
        }
        $type = $data['renderType'];

        if (!is_string($type)) {
            throw new Exception(
                '"renderType" in TCA of field "[' . ($data['tableName'] ?? 'unknown') . '][' . ($data['fieldName'] ?? 'unknown') . ']" does not contain a string. It might be an array instead of a string which could be the result of an array_merge_recursive() operation, for example on existing "fieldControl" keys (these need to have unique array key indices).',
                1739882175
            );
        }
        $className = $this->nodeTypes[$type] ?? $this->nodeTypes['unknown'];

        if (!empty($this->nodeResolver[$type])) {
            // Resolver with the highest priority is called first. If it returns with a new class name,
            // it will be taken and loop is aborted, otherwise resolver with next lower priority is called.
            /** @noinspection PhpUnusedLocalVariableInspection leave for debugging purpose */
            foreach ($this->nodeResolver[$type] as $priority => $resolverClassName) {
                $resolver = $this->initializeNodeResolverClass($resolverClassName, $data);
                // Resolver classes do NOT receive the name of the already resolved class. Single
                // resolvers should not have dependencies to each other or the default implementation,
                // so they also shouldn't know the output of a different resolving class.
                // Additionally, the globalOptions array is NOT given by reference here, changing config is a
                // task of container classes alone and must not be abused here.
                $newClassName = $resolver->resolve();
                if ($newClassName !== null) {
                    $className = $newClassName;
                    break;
                }
            }
        }

        return $this->initializeNodeClass($className, $data);
    }

    /**
     * Add node types from nodeRegistry to $this->nodeTypes.
     * This can be used to add new render types or to overwrite existing node types. The registered class must
     * implement the NodeInterface and will be called if a node with this renderType is rendered.
     *
     * @throws Exception if configuration is incomplete or two nodes with identical priorities are registered
     */
    private function getAdditionalNodeTypesFromConfiguration(array $nodeTypes): array
    {
        // List of additional or override nodes
        $registeredTypeOverrides = $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'];
        // Sanitize input array
        $registeredPrioritiesForNodeNames = [];
        foreach ($registeredTypeOverrides as $override) {
            if (!isset($override['nodeName']) || !isset($override['class']) || !isset($override['priority'])) {
                throw new Exception(
                    'Key class, nodeName or priority missing for an entry in $GLOBALS[\'TYPO3_CONF_VARS\'][\'SYS\'][\'formEngine\'][\'nodeRegistry\']',
                    1432207533
                );
            }
            if ($override['priority'] < 0 || $override['priority'] > 100) {
                throw new Exception(
                    'Priority of element ' . $override['nodeName'] . ' with class ' . $override['class'] . ' is ' . $override['priority'] . ', but must between 0 and 100',
                    1432223531
                );
            }
            if (isset($registeredPrioritiesForNodeNames[$override['nodeName']][$override['priority']])) {
                throw new Exception(
                    'Element ' . $override['nodeName'] . ' already has an override registered with priority ' . $override['priority'],
                    1432223893
                );
            }
            $registeredPrioritiesForNodeNames[$override['nodeName']][$override['priority']] = '';
        }
        // Add element with the highest priority to registry
        $highestPriority = [];
        foreach ($registeredTypeOverrides as $override) {
            if (!isset($highestPriority[$override['nodeName']]) || $override['priority'] > $highestPriority[$override['nodeName']]) {
                $highestPriority[$override['nodeName']] = $override['priority'];
                $nodeTypes[$override['nodeName']] = $override['class'];
            }
        }
        return $nodeTypes;
    }

    /**
     * Add resolver and add them sorted to a local property.
     * This can be used to manipulate the nodeName to class resolution with own code.
     *
     * @throws Exception if configuration is incomplete or two resolver with identical priorities are registered
     */
    private function getRegisteredNodeResolvers(): array
    {
        // List of node resolver
        $registeredNodeResolvers = $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeResolver'];
        $resolversByType = [];
        foreach ($registeredNodeResolvers as $nodeResolver) {
            if (!isset($nodeResolver['nodeName']) || !isset($nodeResolver['class']) || !isset($nodeResolver['priority'])) {
                throw new Exception(
                    'Key class, nodeName or priority missing for an entry in $GLOBALS[\'TYPO3_CONF_VARS\'][\'SYS\'][\'formEngine\'][\'nodeResolver\']',
                    1433155522
                );
            }
            if ($nodeResolver['priority'] < 0 || $nodeResolver['priority'] > 100) {
                throw new Exception(
                    'Priority of element ' . $nodeResolver['nodeName'] . ' with class ' . $nodeResolver['class'] . ' is ' . $nodeResolver['priority'] . ', but must between 0 and 100',
                    1433155563
                );
            }
            if (isset($resolversByType[$nodeResolver['nodeName']][$nodeResolver['priority']])) {
                throw new Exception(
                    'Element ' . $nodeResolver['nodeName'] . ' already has a resolver registered with priority ' . $nodeResolver['priority'],
                    1433155705
                );
            }
            $resolversByType[$nodeResolver['nodeName']][$nodeResolver['priority']] = $nodeResolver['class'];
        }
        $sortedResolversByType = [];
        foreach ($resolversByType as $nodeName => $prioritiesAndClasses) {
            krsort($prioritiesAndClasses);
            $sortedResolversByType[$nodeName] = $prioritiesAndClasses;
        }
        return $sortedResolversByType;
    }

    /**
     * Instantiate a NodeInterface class and set data.
     *
     * @param array $data Main data array
     */
    private function initializeNodeClass(string $className, array $data): NodeInterface
    {
        /** @var NodeInterface $node */
        $node = GeneralUtility::makeInstance($className);
        $node->setData($data);
        return $node;
    }

    /**
     * Instantiate a NodeResolverInterface class and set data.
     *
     * @param array $data Main data array
     */
    private function initializeNodeResolverClass(string $className, array $data): NodeResolverInterface
    {
        /** @var NodeResolverInterface $node */
        $node = GeneralUtility::makeInstance($className);
        $node->setData($data);
        return $node;
    }
}
