<?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\Form\Slot;

use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Resource\Event\BeforeFileAddedEvent;
use TYPO3\CMS\Core\Resource\Event\BeforeFileContentsSetEvent;
use TYPO3\CMS\Core\Resource\Event\BeforeFileCreatedEvent;
use TYPO3\CMS\Core\Resource\Event\BeforeFileMovedEvent;
use TYPO3\CMS\Core\Resource\Event\BeforeFileRenamedEvent;
use TYPO3\CMS\Core\Resource\Event\BeforeFileReplacedEvent;
use TYPO3\CMS\Core\Resource\FolderInterface;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Form\Mvc\Persistence\FormPersistenceManagerInterface;

/**
 * A PSR-14 event listener for various FAL related functionality.
 *
 * @internal will be renamed at some point.
 */
final class FilePersistenceSlot implements SingletonInterface
{
    public const COMMAND_FILE_ADD = 'fileAdd';
    public const COMMAND_FILE_CREATE = 'fileCreate';
    public const COMMAND_FILE_MOVE = 'fileMove';
    public const COMMAND_FILE_RENAME = 'fileRename';
    public const COMMAND_FILE_REPLACE = 'fileReplace';
    public const COMMAND_FILE_SET_CONTENTS = 'fileSetContents';

    private array $allowedInvocations = [];

    public function __construct(private readonly HashService $hashService) {}

    public function getContentSignature(string $content): string
    {
        return $this->hashService->hmac($content, self::class);
    }

    /**
     * Allows invocation for a particular combination of command and file
     * identifier. Commands providing new content have to submit a HMAC
     * signature on the content as well.
     *
     * @see getContentSignature
     */
    public function allowInvocation(string $command, string $combinedFileIdentifier, ?string $contentSignature = null): bool
    {
        $index = $this->searchAllowedInvocation($command, $combinedFileIdentifier, $contentSignature);
        if ($index !== null) {
            return false;
        }
        $this->allowedInvocations[] = [
            'command' => $command,
            'combinedFileIdentifier' => $combinedFileIdentifier,
            'contentSignature' => $contentSignature,
        ];
        return true;
    }

    #[AsEventListener('form-framework/creation')]
    public function onPreFileCreate(BeforeFileCreatedEvent $event): void
    {
        $combinedFileIdentifier = $this->buildCombinedIdentifier($event->getFolder(), $event->getFileName());
        $this->assertFileName(self::COMMAND_FILE_CREATE, $combinedFileIdentifier);
    }

    #[AsEventListener('form-framework/add')]
    public function onPreFileAdd(BeforeFileAddedEvent $event): void
    {
        $combinedFileIdentifier = $this->buildCombinedIdentifier($event->getTargetFolder(), $event->getFileName());
        // While assertFileName() below also checks if it's a form definition
        // we want an early return here to not file_get_contents() below which
        // would be triggered on every file add() command otherwise.
        if (!$this->isFormDefinition($combinedFileIdentifier)) {
            return;
        }
        $this->assertFileName(self::COMMAND_FILE_ADD, $combinedFileIdentifier, (string)file_get_contents($event->getSourceFilePath()));
    }

    #[AsEventListener('form-framework/rename')]
    public function onPreFileRename(BeforeFileRenamedEvent $event): void
    {
        $combinedFileIdentifier = $this->buildCombinedIdentifier($event->getFile()->getParentFolder(), $event->getTargetFileName() ?? '');
        $this->assertFileName(self::COMMAND_FILE_RENAME, $combinedFileIdentifier);
    }

    #[AsEventListener('form-framework/replace')]
    public function onPreFileReplace(BeforeFileReplacedEvent $event): void
    {
        $combinedFileIdentifier = $this->buildCombinedIdentifier($event->getFile()->getParentFolder(), $event->getFile()->getName());
        $this->assertFileName(self::COMMAND_FILE_REPLACE, $combinedFileIdentifier);
    }

    #[AsEventListener('form-framework/move')]
    public function onPreFileMove(BeforeFileMovedEvent $event): void
    {
        // Skip check, in case file extension would not change during this
        // command. In case e.g. "file.txt" shall be renamed to "file.form.yaml"
        // the invocation still has to be granted.
        // Any file moved to a recycle folder is accepted as well.
        if ($this->isFormDefinition($event->getFile()->getIdentifier())
            && $this->isFormDefinition($event->getTargetFileName())
            || $this->isRecycleFolder($event->getFolder())) {
            return;
        }
        $combinedFileIdentifier = $this->buildCombinedIdentifier($event->getFolder(), $event->getTargetFileName());
        $this->assertFileName(self::COMMAND_FILE_MOVE, $combinedFileIdentifier);
    }

    #[AsEventListener('form-framework/update-content')]
    public function onPreFileSetContents(BeforeFileContentsSetEvent $event): void
    {
        $combinedFileIdentifier = $this->buildCombinedIdentifier($event->getFile()->getParentFolder(), $event->getFile()->getName());
        $this->assertFileName(self::COMMAND_FILE_SET_CONTENTS, $combinedFileIdentifier, $event->getContent());
    }

    /**
     * @throws FormDefinitionPersistenceException
     */
    private function assertFileName(string $command, string $combinedFileIdentifier, ?string $content = null): void
    {
        if (!$this->isFormDefinition($combinedFileIdentifier)) {
            return;
        }
        $contentSignature = null;
        if ($content !== null) {
            $contentSignature = $this->getContentSignature($content);
        }
        $allowedInvocationIndex = $this->searchAllowedInvocation($command, $combinedFileIdentifier, $contentSignature);
        if ($allowedInvocationIndex === null) {
            throw new FormDefinitionPersistenceException(
                sprintf('Persisting form definition "%s" is denied', $combinedFileIdentifier),
                1530281202
            );
        }
        unset($this->allowedInvocations[$allowedInvocationIndex]);
    }

    private function searchAllowedInvocation(string $command, string $combinedFileIdentifier, ?string $contentSignature = null): ?int
    {
        foreach ($this->allowedInvocations as $index => $allowedInvocation) {
            if ($command === $allowedInvocation['command']
                && $combinedFileIdentifier === $allowedInvocation['combinedFileIdentifier']
                && $contentSignature === $allowedInvocation['contentSignature']
            ) {
                return $index;
            }
        }
        return null;
    }

    private function buildCombinedIdentifier(FolderInterface $folder, string $fileName): string
    {
        return sprintf('%d:%s%s', $folder->getStorage()->getUid(), $folder->getIdentifier(), $fileName);
    }

    private function isFormDefinition(string $identifier): bool
    {
        return str_ends_with($identifier, FormPersistenceManagerInterface::FORM_DEFINITION_FILE_EXTENSION);
    }

    private function isRecycleFolder(FolderInterface $folder): bool
    {
        $role = $folder->getStorage()->getRole($folder);
        return $role === FolderInterface::ROLE_RECYCLER;
    }
}
