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

use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Clipboard\Type\CountMode;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Authentication\JsConfirmation;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException;
use TYPO3\CMS\Core\Resource\Exception\ResourceDoesNotExistException;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\ProcessedFile;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Utility\PathUtility;

/**
 * TYPO3 clipboard for records and files
 *
 * @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
 */
class Clipboard
{
    /**
     * Clipboard data kept here
     *
     * Keys:
     * 'normal'
     * 'tab_[x]' where x is >=1 and denotes the pad-number
     * 'mode'	:	'copy' means copy-mode, default = moving ('cut')
     * 'el'	:	Array of elements:
     * DB: keys = '[tablename]|[uid]'	eg. 'tt_content:123'
     * DB: values = 1 (basically insignificant)
     * FILE: keys = '_FILE|[md5 of path]'	eg. '_FILE|9ebc7e5c74'
     * FILE: values = The full filepath, eg. '/www/htdocs/typo3/32/dummy/fileadmin/sem1_3_examples/alternative_index.php'
     * or 'C:/www/htdocs/typo3/32/dummy/fileadmin/sem1_3_examples/alternative_index.php'
     *
     * 'current' pointer to current tab (among the above...)
     *
     * The virtual tablename '_FILE' will always indicate files/folders. When checking for elements from eg. 'all tables'
     * (by using an empty string) '_FILE' entries are excluded (so in effect only DB elements are counted)
     */
    public array $clipData = [];

    public bool $changed = false;

    public string $current = '';

    public bool $lockToNormal = false;

    public int $numberOfPads = 3;

    protected ?ServerRequestInterface $request = null;

    public function __construct(
        protected readonly IconFactory $iconFactory,
        protected readonly UriBuilder $uriBuilder,
        protected readonly ResourceFactory $resourceFactory,
        protected readonly TcaSchemaFactory $tcaSchemaFactory,
    ) {}

    /*****************************************
     *
     * Initialize
     *
     ****************************************/
    /**
     * Initialize the clipboard from the be_user session
     */
    public function initializeClipboard(?ServerRequestInterface $request = null): void
    {
        // Initialize the request
        // @todo: Clipboard does two things: It is a repository to find out which records
        //        are in the clipboard, and it is a class to help with rendering the
        //        clipboard. $request is optional and only used in rendering.
        //        It would be better to split these two aspects into single classes.
        $this->request = $request ?? $GLOBALS['TYPO3_REQUEST'] ?? null;

        $userTsConfig = $this->getBackendUser()->getTSConfig();
        // Get data
        $clipData = $this->getBackendUser()->getModuleData('clipboard', !empty($userTsConfig['options.']['saveClipboard']) ? '' : 'ses') ?: [];
        $clipData += ['normal' => []];
        $this->numberOfPads = MathUtility::forceIntegerInRange((int)($userTsConfig['options.']['clipboardNumberPads'] ?? 3), 0, 20);
        // Resets/reinstates the clipboard pads
        $this->clipData['normal'] = is_array($clipData['normal']) ? $clipData['normal'] : [];
        for ($a = 1; $a <= $this->numberOfPads; $a++) {
            $index = 'tab_' . $a;
            $this->clipData[$index] = is_iterable($clipData[$index] ?? null) ? $clipData[$index] : [];
        }
        // Setting the current pad pointer ($this->current))
        $current = (string)($clipData['current'] ?? '');
        $this->current = isset($this->clipData[$current]) ? $current : 'normal';
        $this->clipData['current'] = $this->current;
    }

    /**
     * Call this method after initialization if you want to lock the clipboard to operate on the normal pad only.
     * Trying to switch pad through ->setCmd will not work.
     * This is used by the clickmenu since it only allows operation on single elements at a time (that is the "normal" pad)
     */
    public function lockToNormal(): void
    {
        $this->lockToNormal = true;
        $this->current = 'normal';
    }

    /**
     * The array $cmd may hold various keys which notes some action to take.
     * Normally perform only one action at a time.
     * In scripts like db_list.php / filelist/mod1/index.php the GET-var CB is used to control the clipboard.
     *
     * Selecting / Deselecting elements
     * Array $cmd['el'] has keys = element-ident, value = element value (see description of clipData array in header)
     * Selecting elements for 'copy' should be done by simultaneously setting setCopyMode.
     *
     * @param array $cmd Array of actions, see function description
     */
    public function setCmd(array $cmd): void
    {
        $cmd['el'] ??= [];
        $cmd['el'] = is_iterable($cmd['el']) ? $cmd['el'] : [];
        foreach ($cmd['el'] as $key => $value) {
            if ($this->current === 'normal') {
                unset($this->clipData['normal']);
            }
            if ($value) {
                $this->clipData[$this->current]['el'][$key] = $value;
            } else {
                $this->removeElement((string)$key);
            }
            $this->changed = true;
        }
        // Change clipboard pad (if not locked to normal)
        if ($cmd['setP'] ?? false) {
            $this->setCurrentPad((string)$cmd['setP']);
        }
        // Remove element	(value = item ident: DB; '[tablename]|[uid]'    FILE: '_FILE|[md5 hash of path]'
        if ($cmd['remove'] ?? false) {
            $this->removeElement((string)$cmd['remove']);
            $this->changed = true;
        }
        // Remove all on current pad (value = pad-ident)
        if ($cmd['removeAll'] ?? false) {
            $this->clipData[$cmd['removeAll']] = [];
            $this->changed = true;
        }
        // Set copy mode of the tab
        if (isset($cmd['setCopyMode'])) {
            $this->clipData[$this->current]['mode'] = $cmd['setCopyMode'] ? 'copy' : '';
            $this->changed = true;
        }
    }

    /**
     * Setting the current pad on clipboard
     *
     * @param string $padIdentifier Key in the array $this->clipData
     */
    public function setCurrentPad(string $padIdentifier): void
    {
        // Change clipboard pad (if not locked to normal)
        if (!$this->lockToNormal && $this->current !== $padIdentifier) {
            if (isset($this->clipData[$padIdentifier])) {
                $this->clipData['current'] = ($this->current = $padIdentifier);
            }
            if ($this->current !== 'normal' || !$this->isElements()) {
                $this->clipData[$this->current]['mode'] = '';
            }
            // Setting mode to default (move) if no items on it or if not 'normal'
            $this->changed = true;
        }
    }

    /**
     * Call this after initialization and setCmd in order to save the clipboard to the user session.
     * The function will check if the internal flag ->changed has been set and if so, save the clipboard. Else not.
     */
    public function endClipboard(): void
    {
        if ($this->changed) {
            $this->saveClipboard();
        }
        $this->changed = false;
    }

    /**
     * Cleans up an incoming element array $CBarr (Array selecting/deselecting elements)
     *
     * @param array $CBarr Element array from outside ("key" => "selected/deselected")
     * @param string $table The 'table which is allowed'. Must be set.
     * @param bool $removeDeselected Can be set in order to remove entries which are marked for deselection.
     * @return array Processed input $CBarr
     */
    public function cleanUpCBC(array $CBarr, string $table, bool $removeDeselected = false): array
    {
        foreach ($CBarr as $reference => $value) {
            [$referenceTable] = explode('|', $reference, 2);
            if ($referenceTable !== $table || ($removeDeselected && !$value)) {
                unset($CBarr[$reference]);
            }
        }
        return $CBarr;
    }

    public function getClipboardData(string $table = ''): array
    {
        $lang = $this->getLanguageService();

        $clipboardData = [
            'current' => $this->current,
            'copyMode' => $this->currentMode(),
            'elementCount' => count($this->elFromTable($table)),
        ];

        // Initialize tabs by adding the "normal" tab
        $tabs = [
            [
                'identifier' => 'normal',
                'info' => $this->getTabInfo('normal', $table),
                'title' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.normal'),
                'description' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.normal-description'),
                'items' => $this->current === 'normal' ? $this->getTabItems('normal', $table) : [],
            ],
        ];
        // Add numeric tabs
        for ($a = 1; $a <= $this->numberOfPads; $a++) {
            $tabs[] = [
                'identifier' => 'tab_' . $a,
                'info' => $this->getTabInfo('tab_' . $a, $table),
                'title' => sprintf($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.cliptabs-name'), (string)$a),
                'description' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.cliptabs-description'),
                'items' => $this->current === 'tab_' . $a ? $this->getTabItems('tab_' . $a, $table) : [],
            ];
        }
        // Add tabs to clipboard Data
        $clipboardData['tabs'] = $tabs;

        return $clipboardData;
    }

    /**
     * Get the items for the given pad identifier
     *
     * @param string $padIdentifier Pad reference
     * @return array The tab items
     */
    protected function getTabItems(string $padIdentifier, string $currentTable): array
    {
        if (!is_array($this->clipData[$padIdentifier]['el'] ?? false)) {
            return [];
        }

        $records = [];
        foreach ($this->clipData[$padIdentifier]['el'] as $reference => $value) {
            if (!$value) {
                // Skip element if empty value
                continue;
            }
            [$table, $uid] = explode('|', $reference);
            // Rendering files/directories on the clipboard
            if ($table === '_FILE') {
                $fileObject = $this->resourceFactory->retrieveFileOrFolderObject($value);
                if ($fileObject) {
                    $thumb = '';
                    $folder = $fileObject instanceof Folder;
                    $size = $folder ? '' : '(' . GeneralUtility::formatSize((int)$fileObject->getSize()) . 'bytes)';
                    /** @var File $fileObject */
                    if (!$folder && ($fileObject->isImage() || $fileObject->isMediaFile())) {
                        $processedFile = $fileObject->process(
                            ProcessedFile::CONTEXT_IMAGECROPSCALEMASK,
                            [
                                'maxWidth' => 64,
                                'maxHeight' => 64,
                            ]
                        );
                        $thumb = '<img src="' . htmlspecialchars($processedFile->getPublicUrl() ?? '') . '" '
                            . 'width="' . htmlspecialchars((string)$processedFile->getProperty('width')) . '" '
                            . 'height="' . htmlspecialchars((string)$processedFile->getProperty('height')) . '" '
                            . 'title="' . htmlspecialchars($processedFile->getName()) . '" alt="" loading="lazy" />';
                    }
                    $linkItemText = BackendUtility::cropToTitleLength($fileObject->getName());
                    $combinedIdentifier = $fileObject->getParentFolder()->getCombinedIdentifier();
                    $filesRequested = $currentTable === '_FILE';
                    $records[] = [
                        'identifier' => '_FILE|' . md5($value),
                        'icon' => $this->iconFactory
                            ->getIconForResource($fileObject, IconSize::SMALL)
                            ->setTitle($fileObject->getName() . ' ' . $size)
                            ->render(),
                        'title' => $this->linkItemText(htmlspecialchars($linkItemText), $combinedIdentifier, $filesRequested),
                        'thumb' => $thumb,
                        'infoDataDispatch' => [
                            'action' => 'TYPO3.InfoWindow.showItem',
                            'args' => GeneralUtility::jsonEncodeForHtmlAttribute([$table, $value], false),
                        ],
                    ];
                } else {
                    // If the file did not exist (or is illegal) then it is removed from the clipboard immediately:
                    unset($this->clipData[$padIdentifier]['el'][$reference]);
                    $this->changed = true;
                }
            } else {
                // Rendering records:
                $record = BackendUtility::getRecordWSOL($table, (int)$uid);
                if (is_array($record)) {
                    $isRequestedTable = $currentTable !== '_FILE';
                    $records[] = [
                        'identifier' => $table . '|' . $uid,
                        'icon' => $this->iconFactory->getIconForRecord($table, $record, IconSize::SMALL)->render(),
                        'title' => $this->linkItemText(htmlspecialchars(BackendUtility::cropToTitleLength(BackendUtility::getRecordTitle(
                            $table,
                            $record
                        ))), $record, $isRequestedTable),
                        'infoDataDispatch' => [
                            'action' => 'TYPO3.InfoWindow.showItem',
                            'args' => GeneralUtility::jsonEncodeForHtmlAttribute([$table, (int)$uid], false),
                        ],
                    ];

                    $localizationData = $this->getLocalizations($table, $record, $isRequestedTable);
                    if (!empty($localizationData)) {
                        $records = array_merge($records, $localizationData);
                    }
                } else {
                    unset($this->clipData[$padIdentifier]['el'][$reference]);
                    $this->changed = true;
                }
            }
        }
        $this->endClipboard();
        return $records;
    }

    /**
     * Returns true if the clipboard contains elements
     */
    public function hasElements(): bool
    {
        foreach ($this->clipData as $data) {
            if (isset($data['el']) && is_array($data['el']) && !empty($data['el'])) {
                return true;
            }
        }
        return false;
    }

    /**
     * Gets all localizations of the current record.
     *
     * @param string $table The table
     * @param array $parentRecord The parent record
     * @param bool $isRequestedTable Whether the element is from the requested table
     * @return array HTML table rows
     */
    protected function getLocalizations(string $table, array $parentRecord, bool $isRequestedTable): array
    {
        if (!$this->tcaSchemaFactory->has($table)) {
            return [];
        }
        $schema = $this->tcaSchemaFactory->get($table);
        if (!$schema->isLanguageAware()) {
            return [];
        }

        $languageCapability = $schema->getCapability(TcaSchemaCapability::Language);

        $records = [];
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
        $queryBuilder->getRestrictions()
            ->removeAll()
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
            ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace));

        $queryBuilder
            ->select('*')
            ->from($table)
            ->where(
                $queryBuilder->expr()->eq(
                    $languageCapability->getTranslationOriginPointerField()->getName(),
                    $queryBuilder->createNamedParameter((int)$parentRecord['uid'], Connection::PARAM_INT)
                ),
                $queryBuilder->expr()->neq(
                    $languageCapability->getLanguageField()->getName(),
                    $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
                ),
                $queryBuilder->expr()->gt(
                    'pid',
                    $queryBuilder->createNamedParameter(-1, Connection::PARAM_INT)
                )
            )
            ->orderBy($languageCapability->getLanguageField()->getName());

        foreach ($queryBuilder->executeQuery()->fetchAllAssociative() as $record) {
            $title = htmlspecialchars(BackendUtility::cropToTitleLength(BackendUtility::getRecordTitle($table, $record)));
            if (!$isRequestedTable) {
                // In case the current table is not the requested table, e.g. "_FILE", wrap title in "muted" style
                $title = '<span class="text-variant">' . $title . '</span>';
            }
            $records[] = [
                'icon' => $this->iconFactory->getIconForRecord($table, $record, IconSize::SMALL)->render(),
                'title' => $title,
                'infoDataDispatch' => [
                    'action' => 'TYPO3.InfoWindow.showItem',
                    'args' => GeneralUtility::jsonEncodeForHtmlAttribute([$table, (int)$record['uid']], false),
                ],
            ];
        }

        return $records;
    }

    /**
     * Additional information for the tab. This is either
     * the current copyMode (for "normal") or the elements
     * count, for numeric tabs. Latter will not be shown,
     * in case no elements exist for the tab.
     *
     * @param string $padIdentifier Identifier for the clipboard pad
     * @param string $table The table name to count for elements
     */
    protected function getTabInfo(string $padIdentifier, string $table = ''): string
    {
        $el = count($this->elFromTable($table, $padIdentifier));
        if (!$el) {
            return '';
        }
        $modeLabel = ($this->clipData['normal']['mode'] ?? '') === 'copy' ? $this->clipboardLabel('cm.copy') : $this->clipboardLabel('cm.cut');
        return ' (' . ($padIdentifier === 'normal' ? $modeLabel : htmlspecialchars((string)$el)) . ')';
    }

    /**
     * Wraps the title of the element in a link to the page/folder where they originate from.
     * Will be wrapped into "muted" style in case the element is not from the currently requested table.
     *
     * @param string $itemText Title of element - must be htmlspecialchar'ed on beforehand.
     * @param array|string $reference If array, a record is expected. If string, its the folders' combined identifier
     * @param bool $isRequestedTable Whether the element is from the requested table
     */
    protected function linkItemText(string $itemText, $reference, bool $isRequestedTable): string
    {
        if (is_array($reference)) {
            if ($isRequestedTable) {
                // Wrap in link to corresponding page in recordlist in case current requested table matches
                $itemText = '<a href="' . htmlspecialchars((string)$this->uriBuilder->buildUriFromRoute('records', ['id' => $reference['pid']])) . '">' . $itemText . '</a>';
            } else {
                $itemText = '<span class="text-variant">' . $itemText . '</span>';
            }
        } elseif (is_string($reference)) {
            if ($isRequestedTable && ExtensionManagementUtility::isLoaded('filelist')) {
                // Wrap in link to the files folder in case current requested table matches and filelist is loaded
                $itemText = '<a href="' . htmlspecialchars((string)$this->uriBuilder->buildUriFromRoute('media_management', ['id' => $reference])) . '">' . $itemText . '</a>';
            } else {
                $itemText = '<span class="text-variant">' . $itemText . '</span>';
            }
        }
        return $itemText;
    }

    /**
     * Returns the select-url for database elements
     *
     * @param string $table Table name
     * @param int $uid Uid of record
     * @param bool $copy If set, copymode will be enabled
     * @param bool $deselect If set, the link will deselect, otherwise select.
     * @return string URL linking to the current script but with the CB array set to select the element with table/uid
     */
    public function selUrlDB(string $table, int $uid, bool $copy = false, bool $deselect = false): string
    {
        return $this->buildUrl(['CB' => [
            'el' => [
                $table . '|' . $uid => $deselect ? 0 : 1,
            ],
            'setCopyMode' => (int)$copy,
        ]]);
    }

    /**
     * Returns the select-url for files
     *
     * @param string $path Filepath
     * @param bool $copy If set, copymode will be enabled
     * @param bool $deselect If set, the link will deselect, otherwise select.
     * @return string URL linking to the current script but with the CB array set to select the path
     */
    public function selUrlFile(string $path, bool $copy = false, bool $deselect = false): string
    {
        return $this->buildUrl(['CB' => [
            'el' => [
                '_FILE|' . md5($path) => $deselect ? '' : $path,
            ],
            'setCopyMode' => (int)$copy,
        ]]);
    }

    /**
     * pasteUrl of the element (database and file)
     * For the meaning of $table and $uid, please read from ->makePasteCmdArray!!!
     * The URL will point to tce_file or tce_db depending in $table
     *
     * @param string $table Tablename (_FILE for files)
     * @param string|int $identifier "destination": can be positive or negative indicating how the paste is done
     *                               (paste into / paste after). For files, this is the combined identifier.
     * @param bool $setRedirect If set, then the redirect URL will point back to the current script, but with CB reset.
     * @param array|null $update Additional key/value pairs which should get set in the moved/copied record (via DataHandler)
     */
    public function pasteUrl(string $table, $identifier, bool $setRedirect = true, ?array $update = null): string
    {
        $urlParameters = [
            'CB' => [
                'paste' => $table . '|' . $identifier,
                'pad' => $this->current,
            ],
        ];
        if ($setRedirect) {
            $urlParameters['redirect'] = $this->buildUrl(['CB' => []]);
        }
        if (is_array($update)) {
            $urlParameters['CB']['update'] = $update;
        }
        return (string)$this->uriBuilder->buildUriFromRoute($table === '_FILE' ? 'tce_file' : 'tce_db', $urlParameters);
    }

    /**
     * Returns confirm JavaScript message
     *
     * @param string $table Table name
     * @param array|string $reference For records its an array, for files its a string (path)
     * @param string $type Type-code
     * @return string the text for a confirm message
     */
    public function confirmMsgText(
        string $table,
        $reference,
        string $type,
        CountMode $countMode = CountMode::CURRENT,
    ): string {
        if (!$this->getBackendUser()->jsConfirmation(JsConfirmation::COPY_MOVE_PASTE)) {
            return '';
        }

        $selectedElements = match ($countMode) {
            CountMode::CURRENT => $this->elFromTable($table),
            CountMode::ALL => $this->elFromTable(),
        };

        $labelKey = 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:mess.'
            . ($this->currentMode() === 'copy' ? 'copy' : 'move')
            . ($this->current === 'normal' ? '' : 'cb') . '_' . $type;
        $confirmationMessage = $this->getLanguageService()->sL($labelKey);

        if ($table === '_FILE' && is_string($reference)) {
            $recordTitle = PathUtility::basename($reference);
            if ($this->current === 'normal') {
                $selectedItem = reset($selectedElements);
                $selectedRecordTitle = PathUtility::basename($selectedItem);
            } else {
                $selectedRecordTitle = (string)count($selectedElements);
            }
        } else {
            $recordTitle = $table === 'pages' && !is_array($reference)
                ? $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename']
                : BackendUtility::getRecordTitle($table, $reference);
            if ($this->current === 'normal') {
                $selectedItem = $this->getSelectedRecord();
                $selectedRecordTitle = $selectedItem['_RECORD_TITLE'];
            } else {
                $selectedRecordTitle = (string)count($selectedElements);
            }
        }

        return sprintf(
            $confirmationMessage,
            GeneralUtility::fixed_lgd_cs($selectedRecordTitle, 30),
            GeneralUtility::fixed_lgd_cs($recordTitle, 30)
        );
    }

    /**
     * Clipboard label - getting from "EXT:core/Resources/Private/Language/locallang_core.xlf:"
     *
     * @param string $key Label Key
     * @return string htmspecialchared' label
     */
    protected function clipboardLabel(string $key): string
    {
        return htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:' . $key));
    }

    /*****************************************
     *
     * Helper functions
     *
     ****************************************/
    /**
     * Removes element on clipboard
     *
     * @param string $elementKey Key of element in ->clipData array
     */
    public function removeElement(string $elementKey): void
    {
        unset($this->clipData[$this->current]['el'][$elementKey]);
        $this->changed = true;
    }

    /**
     * Saves the clipboard, no questions asked.
     * Use ->endClipboard normally (as it checks if changes has been done so saving is necessary)
     */
    protected function saveClipboard(): void
    {
        $this->getBackendUser()->pushModuleData('clipboard', $this->clipData);
    }

    /**
     * Returns the current mode, 'copy' or 'cut'
     *
     * @return string "copy" or "cut
     */
    public function currentMode(): string
    {
        return ($this->clipData[$this->current]['mode'] ?? '') === 'copy' ? 'copy' : 'cut';
    }

    /**
     * This traverses the elements on the current clipboard pane
     * and unsets elements which does not exist anymore or are disabled.
     */
    public function cleanCurrent(): void
    {
        if (!is_array($this->clipData[$this->current]['el'] ?? false)) {
            return;
        }

        foreach ($this->clipData[$this->current]['el'] as $reference => $value) {
            [$table, $uid] = explode('|', $reference);
            $unset = false;

            if (!$value) {
                $unset = true;
            } elseif ($table === '_FILE') {
                try {
                    $fileOrFolder = $this->resourceFactory->retrieveFileOrFolderObject($value);

                    if (($fileOrFolder instanceof File || $fileOrFolder instanceof Folder)
                        && !$fileOrFolder->checkActionPermission('read')
                    ) {
                        $unset = true;
                    }
                } catch (InsufficientFolderAccessPermissionsException|ResourceDoesNotExistException) {
                    // If either the file has been deleted in the meantime or the user lacks permissions
                    // for the folder, we just remove the clipboard entry silently
                    $unset = true;
                }
            } elseif (!$this->isRecordAccessAllowed($table, (int)$uid)) {
                $unset = true;
            }

            if ($unset) {
                $this->removeElement($reference);
            }
        }
    }

    protected function isRecordAccessAllowed(string $table, int $uid): bool
    {
        $row = BackendUtility::getRecord($table, (int)$uid, ['uid', 'pid']);
        if (!is_array($row)) {
            return false;
        }

        if (!$this->getBackendUser()->check('tables_select', $table)) {
            return false;
        }

        $schema = $this->tcaSchemaFactory->get($table);
        $rootLevelCapability = $schema->getCapability(TcaSchemaCapability::RestrictionRootLevel);

        $pid = (int)($table === 'pages' ? $row['uid'] : $row['pid']);
        if ($pid === 0) {
            return $this->getBackendUser()->isAdmin() || $rootLevelCapability->canAccessRecordsOnRootLevel();
        }

        $page = BackendUtility::getRecord('pages', $pid);
        if (!is_array($page)) {
            return false;
        }

        if (!$this->getBackendUser()->doesUserHaveAccess($page, Permission::PAGE_SHOW)) {
            return false;
        }

        return true;
    }

    /**
     * Counts the number of elements from the table $matchTable. If $matchTable is blank, all tables (except '_FILE' of course) is counted.
     *
     * @param string $matchTable Table to match/count for.
     * @param string $padIdentifier Can optionally be used to set another pad than the current.
     * @return array Array with keys from the CB.
     */
    public function elFromTable(string $matchTable = '', string $padIdentifier = ''): array
    {
        $padIdentifier = $padIdentifier ?: $this->current;

        if (!is_array($this->clipData[$padIdentifier]['el'] ?? false)) {
            return [];
        }

        $elements = [];
        foreach ($this->clipData[$padIdentifier]['el'] as $reference => $value) {
            if (!$value) {
                continue;
            }
            [$table, $uid] = explode('|', $reference);
            if ($table !== '_FILE') {
                if ((!$matchTable || $table === $matchTable) && $this->tcaSchemaFactory->has($table)) {
                    $elements[$reference] = $padIdentifier === 'normal' ? $value : $uid;
                }
            } elseif ($table === $matchTable) {
                $elements[$reference] = $value;
            }
        }
        return $elements;
    }

    /**
     * Verifies if the item $table/$uid is on the current pad.
     * If the pad is "normal" and the element exists, the mode value is returned.
     * Thus you'll know if the item was copied or cut.
     *
     * @param string $table Table name, (_FILE for files...)
     * @param string|int $identifier Either the records' uid or a filepath
     * @return string If selected the current mode is returned, otherwise an empty string
     */
    public function isSelected(string $table, $identifier): string
    {
        $key = $table . '|' . $identifier;
        $mode = $this->current === 'normal' ? $this->currentMode() : 'any';
        return !empty($this->clipData[$this->current]['el'][$key]) ? $mode : '';
    }

    /**
     * Returns the first element on the current clipboard
     * Makes sense only for DB records - not files!
     *
     * @return array Element record with extra field _RECORD_TITLE set to the title of the record
     */
    public function getSelectedRecord(): array
    {
        $elements = $this->elFromTable();
        reset($elements);
        [$table, $uid] = explode('|', (string)key($elements));
        if (!$this->isSelected($table, (int)$uid)) {
            return [];
        }
        $selectedRecord = BackendUtility::getRecordWSOL($table, (int)$uid);
        $selectedRecord['_RECORD_TITLE'] = BackendUtility::getRecordTitle($table, $selectedRecord);
        return $selectedRecord;
    }

    /**
     * Reports if the current pad has elements (does not check file/DB type OR if file/DBrecord exists or not. Only counting array)
     *
     * @return bool TRUE if elements exist.
     */
    protected function isElements(): bool
    {
        return is_array($this->clipData[$this->current]['el'] ?? null) && !empty($this->clipData[$this->current]['el']);
    }

    protected function getLanguageService(): LanguageService
    {
        return $GLOBALS['LANG'];
    }

    protected function getBackendUser(): BackendUserAuthentication
    {
        return $GLOBALS['BE_USER'];
    }

    /**
     * Builds a URL to the current module with the received
     * parameters, merged / replaced by additional parameters.
     */
    protected function buildUrl(array $parameters = []): string
    {
        if ($this->request === null) {
            throw new \RuntimeException(
                'Request object must be set to generate clipboard URL\'s',
                1633604720
            );
        }
        return (string)$this->uriBuilder->buildUriFromRequest(
            $this->request,
            array_replace($this->request->getQueryParams(), $parameters)
        );
    }
}
