<?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\Core\TypoScript\AST;

use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Core\TypoScript\AST\CurrentObjectPath\CurrentObjectPath;
use TYPO3\CMS\Core\TypoScript\AST\Event\EvaluateModifierFunctionEvent;
use TYPO3\CMS\Core\TypoScript\AST\Node\ChildNode;
use TYPO3\CMS\Core\TypoScript\AST\Node\ChildNodeInterface;
use TYPO3\CMS\Core\TypoScript\AST\Node\NodeInterface;
use TYPO3\CMS\Core\TypoScript\AST\Node\ReferenceChildNode;
use TYPO3\CMS\Core\TypoScript\AST\Node\RootNode;
use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierCopyLine;
use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierReferenceLine;
use TYPO3\CMS\Core\TypoScript\Tokenizer\Line\IdentifierUnsetLine;
use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\ConstantAwareTokenStream;
use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\IdentifierTokenStream;
use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\Token;
use TYPO3\CMS\Core\TypoScript\Tokenizer\Token\TokenStreamInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;

/**
 * Common methods of both AST builders.
 *
 * @internal: Internal AST structure.
 */
abstract class AbstractAstBuilder
{
    /**
     * @var array<string, string>
     */
    protected array $flatConstants = [];
    protected EventDispatcherInterface $eventDispatcher;

    protected function handleIdentifierUnsetLine(IdentifierUnsetLine $line, CurrentObjectPath $currentObjectPath): void
    {
        $node = $currentObjectPath->getFirst();
        $identifierStream = $line->getIdentifierTokenStream()->reset();
        while ($identifierToken = $identifierStream->getNext()) {
            if (!$foundNode = $node->getChildByName($identifierToken->getValue())) {
                break;
            }
            $nextIdentifierToken = $identifierStream->peekNext();
            if ($nextIdentifierToken) {
                $node = $foundNode;
                continue;
            }
            $node->removeChildByName($identifierToken->getValue());
            break;
        }
    }

    protected function handleIdentifierCopyLine(IdentifierCopyLine $line, RootNode $rootNode, CurrentObjectPath $currentObjectPath): ?NodeInterface
    {
        $sourceIdentifierStream = $line->getValueTokenStream()->reset();
        $sourceNode = $rootNode;
        if ($sourceIdentifierStream->isRelative()) {
            // Entry node is current node from current object path if relative, otherwise RootNode.
            $sourceNode = $currentObjectPath->getLast();
        }
        while ($identifierToken = $sourceIdentifierStream->getNext()) {
            // Go through source token stream and locate the sourceNode to copy from.
            if (!$sourceNode = $sourceNode->getChildByName($identifierToken->getValue())) {
                // Source node not found - nothing to do for this line
                return null;
            }
        }
        $isSourceNodeValueNull = true;
        if ($sourceNode->getValue() !== null) {
            // When the source node value is not null, it will override the target node value if that exists.
            $isSourceNodeValueNull = false;
        }

        // Locate/create the targets parent node the copied source should be added as child to,
        // and get the name of the node we're dealing with.
        $targetIdentifierTokenStream = $line->getIdentifierTokenStream()->reset();
        $targetParentNode = $currentObjectPath->getFirst();
        $targetTokenName = null;
        while ($targetToken = $targetIdentifierTokenStream->getNext()) {
            $targetTokenName = $targetToken->getValue();
            if (!($targetIdentifierTokenStream->peekNext() ?? false)) {
                break;
            }
            if (!$foundNode = $targetParentNode->getChildByName($targetTokenName)) {
                // Add new node as new child of current last element in path
                $foundNode = new ChildNode($targetTokenName);
                $targetParentNode->addChild($foundNode);
            }
            $targetParentNode = $foundNode;
        }

        $existingTarget = null;
        if ($isSourceNodeValueNull) {
            // When the node to copy has no value, but the existing target has,
            // the value from the existing target is kept. Also, if the existing
            // node is a ReferenceChildNode and the source does not override this,
            // source children are added to the existing reference instead of
            // dropping the existing target.
            $existingTarget = $targetParentNode->getChildByName($targetTokenName);
            $existingTargetNodeValue = $existingTarget?->getValue();
        } else {
            // Blindly remove existing target node if exists and the value is not overridden by source.
            $targetParentNode->removeChildByName($targetTokenName);
        }
        if ($existingTarget instanceof ReferenceChildNode) {
            // When existing target is a ReferenceChildNode, keep it and
            // copy children from source into existing target.
            $targetNode = $existingTarget;
            foreach ($sourceNode->getNextChild() as $sourceChild) {
                $targetNode->addChild(clone $sourceChild);
            }
        } else {
            // Clone full source node tree, update name and add as child to parent node.
            /** @var ChildNodeInterface $targetNode */
            $targetNode = clone $sourceNode;
            $targetNode->updateName($targetTokenName);
            $targetParentNode->addChild($targetNode);
        }
        if ($isSourceNodeValueNull && $existingTargetNodeValue) {
            // If value of old existing target should be kept, set in now.
            $targetNode->setValue($existingTargetNodeValue);
        }

        return $targetNode;
    }

    /**
     * "foo =< bar": Prepare a reference resolving.
     * Note this does *not* resolve "=<" itself at this point since this operator can only be
     * evaluated after the full AST has been established. Also, having a full AST-traverser run
     * that does this is *very* expensive and "=<" is only done for "tt_content.myElement" and
     * "lib.parseFunc" anyways. As such, "=<" is NOT a language construct itself and the AST-parser
     * only marks nodes that use it by using the special node "ObjectReference".
     * Resolving then happens "lazy" and "on demand" in ContentObjectRenderer cObjGetSingle()
     * and mergeTSRef() for frontend "setup" TypoScript.
     */
    protected function handleIdentifierReferenceLine(IdentifierReferenceLine $line, CurrentObjectPath $currentObjectPath): NodeInterface
    {
        $tokenStream = $line->getIdentifierTokenStream();
        $node = $currentObjectPath->getFirst();
        $identifierStream = $tokenStream->reset();
        while ($identifierToken = $identifierStream->getNext()) {
            $nextIdentifier = $identifierStream->peekNext();
            $identifierTokenValue = $identifierToken->getValue();
            if (!($node->getChildByName($identifierTokenValue)) && $nextIdentifier) {
                // Add new node as new child of current last element in path
                $foundNode = new ChildNode($identifierTokenValue);
                $node->addChild($foundNode);
            } elseif (!$node->getChildByName($identifierTokenValue) && $nextIdentifier === null) {
                // Parent of target node exists, but target node does not. Add new reference child.
                $foundNode = new ReferenceChildNode($identifierTokenValue);
                $foundNode->setReferenceSourceStream($line->getValueTokenStream());
                $node->addChild($foundNode);
            } elseif (($foundNode = $node->getChildByName($identifierTokenValue)) && $nextIdentifier === null) {
                // Target node exists already. We create a new one, remove old, but transfer existing children from old to new.
                $newNode = new ReferenceChildNode($identifierTokenValue);
                $newNode->setReferenceSourceStream($line->getValueTokenStream());
                foreach ($foundNode->getNextChild() as $existingNodeChild) {
                    $newNode->addChild($existingNodeChild);
                }
                $node->removeChildByName($identifierTokenValue);
                $node->addChild($newNode);
                $foundNode = $newNode;
            }
            $node = $foundNode;
        }
        return $node;
    }

    protected function getOrAddNodeFromIdentifierStream(CurrentObjectPath $currentObjectPath, IdentifierTokenStream $tokenStream): NodeInterface
    {
        $node = $currentObjectPath->getFirst();
        $identifierStream = $tokenStream->reset();
        while ($identifierToken = $identifierStream->getNext()) {
            $identifierTokenValue = $identifierToken->getValue();
            if (!$foundNode = $node->getChildByName($identifierTokenValue)) {
                // Add new node as new child of current last element in path
                $foundNode = new ChildNode($identifierTokenValue);
                $node->addChild($foundNode);
            }
            $node = $foundNode;
        }
        return $node;
    }

    /**
     * Evaluate operator functions, example TypoScript:
     * "page.10.value := appendString(foo)"
     */
    protected function evaluateValueModifier(Token $functionNameToken, TokenStreamInterface $functionArgumentTokenStream, ?string $originalValue): ?string
    {
        $functionName = $functionNameToken->getValue();
        // Constants are evaluated via __toString() of ConstantAwareTokenStream and thus need current constants.
        // This implements constants in function arguments: "foo := addToList({$my.constant})"
        if ($functionArgumentTokenStream instanceof ConstantAwareTokenStream) {
            $functionArgumentTokenStream->setFlatConstants($this->flatConstants);
        }
        $functionArgument = (string)$functionArgumentTokenStream;
        switch ($functionName) {
            case 'prependString':
                return $functionArgument . $originalValue;
            case 'appendString':
                return $originalValue . $functionArgument;
            case 'removeString':
                return str_replace($functionArgument, '', $originalValue);
            case 'replaceString':
                $functionValueArray = explode('|', $functionArgument, 2);
                $fromStr = $functionValueArray[0];
                $toStr = $functionValueArray[1] ?? '';
                return str_replace($fromStr, $toStr, $originalValue);
            case 'addToList':
                return ($originalValue !== null ? $originalValue . ',' : '') . $functionArgument;
            case 'removeFromList':
                $existingElements = GeneralUtility::trimExplode(',', $originalValue ?? '');
                $removeElements = GeneralUtility::trimExplode(',', $functionArgument);
                if (!empty($removeElements)) {
                    return implode(',', array_diff($existingElements, $removeElements));
                }
                return $originalValue;
            case 'uniqueList':
                $elements = GeneralUtility::trimExplode(',', $originalValue ?? '');
                return implode(',', array_unique($elements));
            case 'reverseList':
                $elements = GeneralUtility::trimExplode(',', $originalValue ?? '');
                return implode(',', array_reverse($elements));
            case 'sortList':
                $elements = GeneralUtility::trimExplode(',', $originalValue ?? '');
                $arguments = GeneralUtility::trimExplode(',', $functionArgument);
                $arguments = array_map('strtolower', $arguments);
                $sortFlags = SORT_REGULAR;
                if (in_array('numeric', $arguments)) {
                    $sortFlags = SORT_NUMERIC;
                    // If the sorting modifier "numeric" is given, all values
                    // are checked and an exception is thrown if a non-numeric value is given
                    // otherwise there is a different behaviour between PHP 7 and PHP 5.x
                    // See also the warning on http://us.php.net/manual/en/function.sort.php
                    foreach ($elements as $element) {
                        if (!is_numeric($element)) {
                            throw new \InvalidArgumentException(
                                'The list "' . $originalValue . '" should be sorted numerically but contains a non-numeric value',
                                1650893781
                            );
                        }
                    }
                }
                sort($elements, $sortFlags);
                if (in_array('descending', $arguments)) {
                    $elements = array_reverse($elements);
                }
                return implode(',', $elements);
            case 'getEnv':
                $environmentValue = getenv(trim($functionArgument));
                if ($environmentValue !== false) {
                    return $environmentValue;
                }
                return $originalValue;
            default:
                return $this->eventDispatcher->dispatch(new EvaluateModifierFunctionEvent($functionName, $functionArgument, $originalValue))->getValue() ?? $originalValue;
        }
    }
}
