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

use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Configuration\TranslationConfigurationProvider;
use TYPO3\CMS\Backend\Controller\Event\AfterFormEnginePageInitializedEvent;
use TYPO3\CMS\Backend\Controller\Event\BeforeFormEnginePageInitializedEvent;
use TYPO3\CMS\Backend\Form\Exception\AccessDeniedException;
use TYPO3\CMS\Backend\Form\Exception\DatabaseRecordException;
use TYPO3\CMS\Backend\Form\Exception\DatabaseRecordWorkspaceDeletePlaceholderException;
use TYPO3\CMS\Backend\Form\Exception\NoFieldsToRenderException;
use TYPO3\CMS\Backend\Form\FormDataCompiler;
use TYPO3\CMS\Backend\Form\FormDataGroup\TcaDatabaseRecord;
use TYPO3\CMS\Backend\Form\FormResultCompiler;
use TYPO3\CMS\Backend\Form\NodeFactory;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Routing\Exception\ResourceNotFoundException;
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\Buttons\GenericButton;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\Database\ReferenceIndex;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\DataHandling\TableColumnType;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Resource\Exception\FileDoesNotExistException;
use TYPO3\CMS\Core\Resource\Exception\InsufficientUserPermissionsException;
use TYPO3\CMS\Core\Resource\FileInterface;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Routing\BackendEntryPointResolver;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\HttpUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
use TYPO3\CMS\Core\Versioning\VersionState;

/**
 * Main backend controller almost always used if some database record is edited in the backend.
 *
 * Main job of this controller is to evaluate and sanitize $request parameters,
 * call the DataHandler if records should be created or updated and
 * execute FormEngine for record rendering.
 */
#[AsController]
class EditDocumentController
{
    protected const DOCUMENT_CLOSE_MODE_DEFAULT = 0;
    // works like DOCUMENT_CLOSE_MODE_DEFAULT
    protected const DOCUMENT_CLOSE_MODE_REDIRECT = 1;
    protected const DOCUMENT_CLOSE_MODE_CLEAR_ALL = 3;
    protected const DOCUMENT_CLOSE_MODE_NO_REDIRECT = 4;

    /**
     * An array looking approx like [tablename][list-of-ids]=command, eg. "&edit[pages][123]=edit".
     *
     * @var array<string,array>
     */
    protected $editconf = [];

    /**
     * Array of tables with a lists of field names to edit for those tables. If specified, only those fields
     * will be rendered. Otherwise all (available) fields in the record are shown according to the TCA type.
     */
    protected ?array $columnsOnly = null;

    /**
     * Default values for fields
     *
     * @var array|null [table][field]
     */
    protected $defVals;

    /**
     * Array of values to force being set as hidden fields in FormEngine
     *
     * @var array|null [table][field]
     */
    protected $overrideVals;

    /**
     * If set, this value will be set in $this->retUrl as "returnUrl", if not,
     * $this->retUrl will link to dummy controller
     *
     * @var string|null
     */
    protected $returnUrl;

    /**
     * Prepared return URL. Contains the URL that we should return to from FormEngine if
     * close button is clicked. Usually passed along as 'returnUrl', but falls back to
     * "dummy" controller.
     *
     * @var string
     */
    protected $retUrl;

    /**
     * Close document command. One of the DOCUMENT_CLOSE_MODE_* constants above
     */
    protected int $closeDoc;

    /**
     * If true, the processing of incoming data will be performed as if a save-button is pressed.
     * Used in the forms as a hidden field which can be set through
     * JavaScript if the form is somehow submitted by JavaScript.
     *
     * @var bool
     */
    protected $doSave;

    /**
     * Main DataHandler datamap array
     *
     * @var array
     */
    protected $data;

    /**
     * Main DataHandler cmdmap array
     *
     * @var array
     */
    protected $cmd;

    /**
     * DataHandler 'mirror' input
     *
     * @var array
     */
    protected $mirror;

    /**
     * Boolean: If set, then the GET var "&id=" will be added to the
     * retUrl string so that the NEW id of something is returned to the script calling the form.
     *
     * @var bool
     */
    protected $returnNewPageId = false;

    /**
     * ID for displaying the page in the frontend, "save and view"
     *
     * @var int
     */
    protected $popViewId;

    /**
     * @var string|null
     */
    protected $previewCode;

    /**
     * Alternative title for the document handler.
     *
     * @var string
     */
    protected $recTitle;

    /**
     * If set, then no save & view button is printed
     *
     * @var bool
     */
    protected $noView;

    /**
     * @var string
     */
    protected $perms_clause;

    /**
     * If true, $this->editconf array is added a redirect response, used by Wizard/AddController
     *
     * @var bool
     */
    protected $returnEditConf;

    /**
     * parse_url() of current requested URI, contains ['path'] and ['query'] parts.
     *
     * @var array
     */
    protected $R_URL_parts;

    /**
     * Contains $request query parameters. This array is the foundation for creating
     * the R_URI internal var which becomes the url to which forms are submitted
     *
     * @var array
     */
    protected $R_URL_getvars;

    /**
     * Set to the URL of this script including variables which is needed to re-display the form.
     *
     * @var string
     */
    protected $R_URI;

    /**
     * @var array
     */
    protected $pageinfo;

    /**
     * Is loaded with the "title" of the currently "open document"
     * used for the open document toolbar
     *
     * @var string
     */
    protected $storeTitle = '';

    /**
     * Contains an array with key/value pairs of GET parameters needed to reach the
     * current document displayed - used in the 'open documents' toolbar.
     *
     * @var array
     */
    protected $storeArray;

    /**
     * $this->storeArray imploded to url
     *
     * @var string
     */
    protected $storeUrl;

    /**
     * md5 hash of storeURL, used to identify a single open document in backend user uc
     *
     * @var string
     */
    protected $storeUrlMd5;

    /**
     * Backend user session data of this module
     *
     * @var array
     */
    protected $docDat;

    /**
     * An array of the "open documents" - keys are md5 hashes (see $storeUrlMd5) identifying
     * the various documents on the GET parameter list needed to open it. The values are
     * arrays with 0,1,2 keys with information about the document (see compileStoreData()).
     * The docHandler variable is stored in the $docDat session data, key "0".
     *
     * @var array
     */
    protected $docHandler;

    /**
     * Array of the elements to create edit forms for.
     *
     * @var array
     */
    protected $elementsData;

    /**
     * Pointer to the first element in $elementsData
     *
     * @var array
     */
    protected $firstEl;

    /**
     * Counter, used to count the number of errors (when users do not have edit permissions)
     *
     * @var int
     */
    protected $errorC;

    /**
     * Is set to the pid value of the last shown record - thus indicating which page to
     * show when clicking the SAVE/VIEW button
     *
     * @var int
     */
    protected $viewId;

    /**
     * @var FormResultCompiler
     */
    protected $formResultCompiler;

    /**
     * Used internally to disable the storage of the document reference (eg. new records)
     *
     * @var int
     */
    protected $dontStoreDocumentRef = 0;

    /**
     * True if a record has been saved
     */
    protected bool $isSavedRecord = false;

    protected bool $isPageInFreeTranslationMode = false;

    public function __construct(
        protected readonly EventDispatcherInterface $eventDispatcher,
        protected readonly IconFactory $iconFactory,
        protected readonly PageRenderer $pageRenderer,
        protected readonly UriBuilder $uriBuilder,
        protected readonly ModuleTemplateFactory $moduleTemplateFactory,
        protected readonly BackendEntryPointResolver $backendEntryPointResolver,
        protected readonly ModuleProvider $moduleProvider,
        private readonly FormDataCompiler $formDataCompiler,
        private readonly NodeFactory $nodeFactory,
        protected TcaSchemaFactory $tcaSchemaFactory,
    ) {}

    /**
     * Main dispatcher entry method registered as "record_edit" end point.
     */
    public function mainAction(ServerRequestInterface $request): ResponseInterface
    {
        $view = $this->moduleTemplateFactory->create($request);
        $view->setUiBlock(true);
        $view->setTitle($this->getShortcutTitle($request));

        // Unlock all locked records
        BackendUtility::lockRecords();
        if ($response = $this->preInit($request)) {
            return $response;
        }

        // Process incoming data via DataHandler?
        $parsedBody = $request->getParsedBody();
        if ((
            $this->doSave
                || isset($parsedBody['_savedok'])
                || isset($parsedBody['_saveandclosedok'])
                || isset($parsedBody['_savedokview'])
                || isset($parsedBody['_savedoknew'])
                || isset($parsedBody['_duplicatedoc'])
        )
            && $request->getMethod() === 'POST'
            && $response = $this->processData($view, $request)
        ) {
            return $response;
        }

        $this->init($request);

        if ($request->getMethod() === 'POST') {
            // In case save&view is requested, we have to add this information to the redirect
            // URL, since the ImmediateAction will be added to the module body afterwards.
            if (isset($parsedBody['_savedokview'])) {
                $this->R_URI = rtrim($this->R_URI, '&') .
                    HttpUtility::buildQueryString([
                        'showPreview' => true,
                        'popViewId' => $parsedBody['popViewId'] ?? PreviewUriBuilder::getPreviewPageId(
                            ($this->firstEl['table'] ?? ''),
                            ($this->firstEl['uid'] ?? ''),
                            ($this->popViewId ?: $this->viewId),
                        ),
                    ], (empty($this->R_URL_getvars) ? '?' : '&'));
            }
            return new RedirectResponse($this->R_URI, 302);
        }

        $view->assign('bodyHtml', $this->main($view, $request));
        return $view->renderResponse('Form/EditDocument');
    }

    /**
     * First initialization, always called, even before processData() executes DataHandler processing.
     */
    protected function preInit(ServerRequestInterface $request): ?ResponseInterface
    {
        if ($response = $this->localizationRedirect($request)) {
            return $response;
        }

        $parsedBody = $request->getParsedBody();
        $queryParams = $request->getQueryParams();

        $this->editconf = $parsedBody['edit'] ?? $queryParams['edit'] ?? [];
        $this->defVals = $parsedBody['defVals'] ?? $queryParams['defVals'] ?? null;
        $this->overrideVals = $parsedBody['overrideVals'] ?? $queryParams['overrideVals'] ?? null;
        $this->returnUrl = GeneralUtility::sanitizeLocalUrl($parsedBody['returnUrl'] ?? $queryParams['returnUrl'] ?? '');
        $this->closeDoc = (int)($parsedBody['closeDoc'] ?? $queryParams['closeDoc'] ?? self::DOCUMENT_CLOSE_MODE_DEFAULT);
        $this->doSave = ($parsedBody['doSave'] ?? false) && $request->getMethod() === 'POST';
        $this->returnEditConf = (bool)($parsedBody['returnEditConf'] ?? $queryParams['returnEditConf'] ?? false);

        $columnsOnly = $parsedBody['columnsOnly'] ?? $queryParams['columnsOnly'] ?? null;
        if (is_string($columnsOnly) && $columnsOnly !== '') {
            // @deprecated remove fallback in v14
            // Store given columns for the first table - only for b/w compatibility
            trigger_error(
                'Providing columnsOnly with no table context is deprecated and will be removed in v14. Define columnsOnly[table][]=field instead.',
                E_USER_DEPRECATED
            );
            $tables = array_keys($this->editconf);
            foreach ($tables as $table) {
                $this->columnsOnly[$table] = GeneralUtility::trimExplode(',', $columnsOnly, true);
            }
        }
        if (is_array($columnsOnly) && $columnsOnly !== []) {
            foreach ($columnsOnly as $table => $fields) {
                $this->columnsOnly[$table] = is_array($fields) ? $fields : GeneralUtility::trimExplode(',', $fields, true);
            }
        }

        // Set overrideVals as default values if defVals does not exist.
        // @todo: Why?
        if (!is_array($this->defVals) && is_array($this->overrideVals)) {
            $this->defVals = $this->overrideVals;
        }
        $this->addSlugFieldsToColumnsOnly($queryParams);

        // Set final return URL
        $this->retUrl = $this->returnUrl ?: $this->resolveDefaultReturnUrl();

        // Change $this->editconf if versioning applies to any of the records
        $this->fixWSversioningInEditConf();

        // Prepare R_URL (request url)
        $this->R_URL_parts = parse_url($request->getAttribute('normalizedParams')->getRequestUri()) ?: [];
        $this->R_URL_getvars = $queryParams;
        $this->R_URL_getvars['edit'] = $this->editconf;

        // Prepare 'open documents' url, this is later modified again various times
        $this->compileStoreData($request);
        // Backend user session data of this module
        $this->docDat = $this->getBackendUser()->getModuleData('FormEngine', 'ses');
        $this->docHandler = $this->docDat[0] ?? [];

        // Close document if a request for closing the document has been sent
        if ($this->closeDoc > self::DOCUMENT_CLOSE_MODE_DEFAULT) {
            if ($response = $this->closeDocument($this->closeDoc, $request)) {
                return $response;
            }
        }

        $event = new BeforeFormEnginePageInitializedEvent($this, $request);
        $this->eventDispatcher->dispatch($event);
        return null;
    }

    /**
     * Always add required fields of slug field
     */
    protected function addSlugFieldsToColumnsOnly(array $queryParams): void
    {
        $data = $queryParams['edit'] ?? [];
        $tables = array_keys($data);
        foreach ($tables as $table) {
            if (!empty($this->columnsOnly[$table]) && $this->tcaSchemaFactory->has($table)) {
                $schema = $this->tcaSchemaFactory->get($table);
                foreach ($this->columnsOnly[$table] as $field) {
                    if (!$schema->hasField($field)) {
                        continue;
                    }
                    $field = $schema->getField($field);
                    $postModifiers = $field->getConfiguration()['generatorOptions']['postModifiers'] ?? [];
                    if ($field->isType(TableColumnType::SLUG)
                        && (!is_array($postModifiers) || $postModifiers === [])
                    ) {
                        $fieldGroups = $field->getConfiguration()['generatorOptions']['fields'] ?? [];
                        if (is_string($fieldGroups)) {
                            $fieldGroups = [$fieldGroups];
                        }
                        foreach ($fieldGroups as $fields) {
                            $this->columnsOnly['__hiddenGeneratorFields'][$table] = array_merge(
                                $this->columnsOnly['__hiddenGeneratorFields'][$table] ?? [],
                                (is_array($fields) ? $fields : GeneralUtility::trimExplode(',', $fields, true))
                            );
                        }
                    }
                }
                if (!empty($this->columnsOnly['__hiddenGeneratorFields'][$table])) {
                    $this->columnsOnly['__hiddenGeneratorFields'][$table] = array_diff(
                        array_unique($this->columnsOnly['__hiddenGeneratorFields'][$table]),
                        $this->columnsOnly[$table]
                    );
                }
            }
        }
    }

    /**
     * Do processing of data, submitting it to DataHandler. May return a RedirectResponse.
     */
    protected function processData(ModuleTemplate $view, ServerRequestInterface $request): ?ResponseInterface
    {
        $parsedBody = $request->getParsedBody();

        $beUser = $this->getBackendUser();

        // Processing related GET / POST vars
        $this->data = $parsedBody['data'] ?? [];
        $this->cmd = $parsedBody['cmd'] ?? [];
        $this->mirror = $parsedBody['mirror']  ?? [];
        $this->returnNewPageId = (bool)($parsedBody['returnNewPageId'] ?? false);

        // Only options related to $this->data submission are included here
        $tce = GeneralUtility::makeInstance(DataHandler::class);

        $tce->setControl($parsedBody['control'] ?? []);

        // Set internal vars
        if (isset($beUser->uc['neverHideAtCopy']) && $beUser->uc['neverHideAtCopy']) {
            $tce->neverHideAtCopy = true;
        }

        // Set default values fetched previously from GET / POST vars
        if (is_array($this->defVals) && $this->defVals !== []) {
            $tce->defaultValues = array_merge_recursive($this->defVals, $tce->defaultValues);
        }

        // Load DataHandler with data
        $tce->start($this->data, $this->cmd);
        if (is_array($this->mirror)) {
            $tce->setMirror($this->mirror);
        }

        // Perform the saving operation with DataHandler:
        if ($this->doSave === true) {
            $tce->process_datamap();
            $tce->process_cmdmap();

            // Update the module menu for the current backend user, as they updated their UI language
            $currentUserId = $beUser->getUserId();
            if ($currentUserId
                && (string)($this->data['be_users'][$currentUserId]['lang'] ?? '') !== ''
                && $this->data['be_users'][$currentUserId]['lang'] !== $beUser->user['lang']
            ) {
                $newLanguageKey = $this->data['be_users'][$currentUserId]['lang'];
                // Update the current backend user language as well
                $beUser->user['lang'] = $newLanguageKey;
                // Re-create LANG to have the current request updated the translated page as well
                $this->getLanguageService()->init($newLanguageKey);
                BackendUtility::setUpdateSignal('updateModuleMenu');
                BackendUtility::setUpdateSignal('updateTopbar');
            }
        }
        // If pages are being edited, we set an instruction about updating the page tree after this operation.
        if ($tce->pagetreeNeedsRefresh
            && (isset($this->data['pages']) || $beUser->workspace !== 0 && !empty($this->data))
        ) {
            BackendUtility::setUpdateSignal('updatePageTree');
        }
        // If there was saved any new items, load them:
        if (!empty($tce->substNEWwithIDs_table)) {
            // Save the expanded/collapsed states for new inline records, if any
            $this->updateInlineView($request->getParsedBody()['uc'] ?? $request->getQueryParams()['uc'] ?? null, $tce);
            $newEditConf = [];
            foreach ($this->editconf as $tableName => $tableCmds) {
                $keys = array_keys($tce->substNEWwithIDs_table, $tableName);
                if (!empty($keys)) {
                    foreach ($keys as $key) {
                        $editId = $tce->substNEWwithIDs[$key];
                        // Check if the $editId isn't a child record of an IRRE action
                        if (!(is_array($tce->newRelatedIDs[$tableName] ?? null)
                            && in_array($editId, $tce->newRelatedIDs[$tableName]))
                        ) {
                            // Translate new id to the workspace version
                            if ($versionRec = BackendUtility::getWorkspaceVersionOfRecord(
                                $beUser->workspace,
                                $tableName,
                                $editId,
                                'uid'
                            )) {
                                $editId = $versionRec['uid'];
                            }
                            $newEditConf[$tableName][$editId] = 'edit';
                        }
                        // Traverse all new records and forge the content of ->editconf so we can continue to edit these records!
                        if ($tableName === 'pages'
                            && $this->retUrl !== (string)$this->uriBuilder->buildUriFromRoute('dummy')
                            && $this->retUrl !== $this->getCloseUrl()
                            && $this->returnNewPageId
                        ) {
                            $this->retUrl .= '&id=' . $tce->substNEWwithIDs[$key];
                        }
                    }
                } else {
                    $newEditConf[$tableName] = $tableCmds;
                }
            }
            // Reset editconf if newEditConf has values
            if (!empty($newEditConf)) {
                $this->editconf = $newEditConf;
            }
            // Finally, set the editconf array in the "getvars" so they will be passed along in URLs as needed.
            $this->R_URL_getvars['edit'] = $this->editconf;
            // Unset default values since we don't need them anymore.
            unset($this->R_URL_getvars['defVals']);
            // Recompile the store* values since editconf changed
            $this->compileStoreData($request);
        }
        // See if any records was auto-created as new versions?
        if (!empty($tce->autoVersionIdMap)) {
            $this->fixWSversioningInEditConf($tce->autoVersionIdMap);
        }
        // If a document is saved and a new one is created right after.
        if (isset($parsedBody['_savedoknew']) && is_array($this->editconf)) {
            if ($redirect = $this->closeDocument(self::DOCUMENT_CLOSE_MODE_NO_REDIRECT, $request)) {
                return $redirect;
            }
            // Find the current table
            reset($this->editconf);
            $nTable = (string)key($this->editconf);
            // Determine insertion mode: 'top' is self-explaining,
            // otherwise new elements are inserted after one using a negative uid
            $insertRecordOnTop = ($this->getTsConfigOption($nTable, 'saveDocNew') === 'top');
            // Fetching id's - might be a comma-separated list
            reset($this->editconf[$nTable]);
            $ids = GeneralUtility::trimExplode(',', (string)key($this->editconf[$nTable]), true);
            // Depending on $insertRecordOnTop, retrieve either the first or last id to get the records' pid+uid
            if ($insertRecordOnTop) {
                $nUid = (int)reset($ids);
            } else {
                $nUid = (int)end($ids);
            }
            $recordFields = 'pid,uid';
            if ($this->tcaSchemaFactory->get($nTable)->isWorkspaceAware()) {
                $recordFields .= ',t3ver_oid';
            }
            $nRec = BackendUtility::getRecord($nTable, $nUid, $recordFields);
            // Setting a blank editconf array for a new record:
            $this->editconf = [];
            // Determine related page ID for regular live context
            if ((int)($nRec['t3ver_oid'] ?? 0) === 0) {
                if ($insertRecordOnTop) {
                    $relatedPageId = $nRec['pid'];
                } else {
                    $relatedPageId = -$nRec['uid'];
                }
            } else {
                // Determine related page ID for workspace context
                if ($insertRecordOnTop) {
                    // Fetch live version of workspace version since the pid value is always -1 in workspaces
                    $liveRecord = BackendUtility::getRecord($nTable, $nRec['t3ver_oid'], $recordFields);
                    $relatedPageId = $liveRecord['pid'];
                } else {
                    // Use uid of live version of workspace version
                    $relatedPageId = -$nRec['t3ver_oid'];
                }
            }
            $this->editconf[$nTable][$relatedPageId] = 'new';
            // Finally, set the editconf array in the "getvars" so they will be passed along in URLs as needed.
            $this->R_URL_getvars['edit'] = $this->editconf;
            // Recompile the store* values since editconf changed...
            $this->compileStoreData($request);
        }

        // Explicitly require a save operation
        if ($this->doSave) {
            $erroneousRecords = $tce->printLogErrorMessages();
            $messages = [];
            $table = (string)key($this->editconf);
            $uidList = GeneralUtility::intExplode(',', (string)key($this->editconf[$table]));

            foreach ($uidList as $uid) {
                $uid = (int)abs($uid);
                if (!in_array($table . '.' . $uid, $erroneousRecords, true)) {
                    $realUidInPayload = ($tceSubstId = array_search($uid, $tce->substNEWwithIDs, true)) !== false ? $tceSubstId : $uid;
                    $row = $this->data[$table][$uid] ?? $this->data[$table][$realUidInPayload] ?? null;
                    if ($row === null) {
                        continue;
                    }
                    // Ensure, uid is always available to make labels with foreign table lookups possible
                    $row['uid'] ??= $realUidInPayload;
                    // If the label column of the record is not available, fetch it from database.
                    // This is the when EditDocumentController is booted in single field mode (e.g.
                    // Template module > 'info/modify' > edit 'setup' field) or in case the field is
                    // not in "showitem" or is set to readonly (e.g. "file" in sys_file_metadata).
                    $labelCapability = $this->tcaSchemaFactory->get($table)->getCapability(TcaSchemaCapability::Label);
                    $labelFields = $labelCapability->getAllLabelFieldNames();
                    foreach ($labelFields as $labelField) {
                        if (!isset($row[$labelField])) {
                            $tmpRecord = BackendUtility::getRecord($table, $uid, implode(',', $labelFields));
                            if ($tmpRecord !== null) {
                                $row = array_merge($row, $tmpRecord);
                            }
                            break;
                        }
                    }
                    $recordTitle = GeneralUtility::fixed_lgd_cs(BackendUtility::getRecordTitle($table, $row), (int)$this->getBackendUser()->uc['titleLen']);
                    $messages[] = sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:notification.record_saved.message'), $recordTitle);
                }
            }

            // Add messages to the flash message container only if the request is a save action (excludes "duplicate")
            if ($messages !== []) {
                $label = $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:notification.record_saved.title.plural');
                if (count($messages) === 1) {
                    $label = $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:notification.record_saved.title.singular');
                }
                if (count($messages) > 10) {
                    $messages = [sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:notification.mass_saving.message'), count($messages))];
                }
                $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class);
                $defaultFlashMessageQueue = $flashMessageService->getMessageQueueByIdentifier(FlashMessageQueue::NOTIFICATION_QUEUE);
                $flashMessage = GeneralUtility::makeInstance(
                    FlashMessage::class,
                    implode(LF, $messages),
                    $label,
                    ContextualFeedbackSeverity::OK,
                    true
                );
                $defaultFlashMessageQueue->enqueue($flashMessage);
            }
        }

        // If a document should be duplicated.
        if (isset($parsedBody['_duplicatedoc']) && is_array($this->editconf)) {
            $this->closeDocument(self::DOCUMENT_CLOSE_MODE_NO_REDIRECT, $request);
            // Find current table
            reset($this->editconf);
            $nTable = (string)key($this->editconf);
            // Find the first id, getting the records pid+uid
            reset($this->editconf[$nTable]);
            $nUid = key($this->editconf[$nTable]);
            if (!MathUtility::canBeInterpretedAsInteger($nUid)) {
                $nUid = $tce->substNEWwithIDs[$nUid];
            }

            $recordFields = 'pid,uid';
            if ($this->tcaSchemaFactory->get($nTable)->isWorkspaceAware()) {
                $recordFields .= ',t3ver_oid';
            }
            $nRec = BackendUtility::getRecord($nTable, $nUid, $recordFields);

            // Setting a blank editconf array for a new record:
            $this->editconf = [];

            if ((int)($nRec['t3ver_oid'] ?? 0) === 0) {
                $relatedPageId = -$nRec['uid'];
            } else {
                $relatedPageId = -$nRec['t3ver_oid'];
            }

            $duplicateTce = GeneralUtility::makeInstance(DataHandler::class);

            $duplicateCmd = [
                $nTable => [
                    $nUid => [
                        'copy' => $relatedPageId,
                    ],
                ],
            ];

            $duplicateTce->start([], $duplicateCmd);
            $duplicateTce->process_cmdmap();
            $duplicateMappingArray = $duplicateTce->copyMappingArray;

            if (isset($duplicateMappingArray[$nTable][$nUid])) {
                $duplicateUid = $duplicateMappingArray[$nTable][$nUid];
                if ($nTable === 'pages') {
                    BackendUtility::setUpdateSignal('updatePageTree');
                }

                $this->editconf[$nTable][$duplicateUid] = 'edit';
                // Finally, set the editconf array in the "getvars" so they will be passed along in URLs as needed.
                $this->R_URL_getvars['edit'] = $this->editconf;
                // Recompile the store* values since editconf changed...
                $this->compileStoreData($request);

                // Inform the user of the duplication
                $view->addFlashMessage($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.recordDuplicated'));
            } else {
                // Inform the user about the failed duplication
                $view->addFlashMessage(
                    $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.recordDuplicationFailed'),
                    '',
                    ContextualFeedbackSeverity::ERROR
                );
            }
        }

        if ($this->closeDoc < self::DOCUMENT_CLOSE_MODE_DEFAULT
            || isset($parsedBody['_saveandclosedok'])
        ) {
            // Redirect if element should be closed after save
            return $this->closeDocument((int)abs($this->closeDoc), $request);
        }
        return null;
    }

    /**
     * Initialize the view part of the controller logic.
     */
    protected function init(ServerRequestInterface $request): void
    {
        $parsedBody = $request->getParsedBody();
        $queryParams = $request->getQueryParams();
        $beUser = $this->getBackendUser();

        $this->popViewId = (int)($parsedBody['popViewId'] ?? $queryParams['popViewId'] ?? 0);
        $this->recTitle = (string)($parsedBody['recTitle'] ?? $queryParams['recTitle'] ?? '');
        $this->noView = (bool)($parsedBody['noView'] ?? $queryParams['noView'] ?? false);
        $this->perms_clause = $beUser->getPagePermsClause(Permission::PAGE_SHOW);

        // Preview code is implicit only generated for GET requests, having the query
        // parameters "popViewId" (the preview page id) and "showPreview" set.
        if ($this->popViewId && ($queryParams['showPreview'] ?? false)) {
            // Generate the preview code (markup), which is added to the module body later
            $this->previewCode = $this->getPreviewUriBuilderForRecordPreview()->buildImmediateActionElement([PreviewUriBuilder::OPTION_SWITCH_FOCUS => null]);
            // After generating the preview code, those params should not longer be applied to the form
            // action, as this would otherwise always refresh the preview window on saving the record.
            unset($this->R_URL_getvars['showPreview'], $this->R_URL_getvars['popViewId']);
        }

        // Set other internal variables:
        $this->R_URL_getvars['returnUrl'] = $this->retUrl;
        $this->R_URI = $this->R_URL_parts['path'] . HttpUtility::buildQueryString($this->R_URL_getvars, '?');

        $this->pageRenderer->getJavaScriptRenderer()->includeTaggedImports('backend.form');
        $this->pageRenderer->addInlineLanguageLabelFile('EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf');
        $this->pageRenderer->addInlineSetting('ShowItem', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('show_item'));

        $event = new AfterFormEnginePageInitializedEvent($this, $request);
        $this->eventDispatcher->dispatch($event);
    }

    protected function getPreviewUriBuilderForRecordPreview(): PreviewUriBuilder
    {
        $array_keys = array_keys($this->editconf);
        $table = (string)(reset($array_keys) ?: null);
        if ($table) {
            $array_keys = array_keys($this->editconf[$table]);
        } else {
            $table = (string)($this->firstEl['table'] ?? '');
        }
        $recordId = (int)((reset($array_keys) ?: null) ?? ($this->firstEl['uid'] ?? ''));
        $pageId = $this->popViewId ?: $this->viewId;
        return PreviewUriBuilder::createForRecordPreview($table, $recordId, $pageId);
    }

    /**
     * Main module operation
     */
    protected function main(ModuleTemplate $view, ServerRequestInterface $request): string
    {
        $body = $this->previewCode ?? '';
        // Begin edit
        if (is_array($this->editconf)) {
            $this->formResultCompiler = GeneralUtility::makeInstance(FormResultCompiler::class);

            // Creating the editing form, wrap it with buttons, document selector etc.
            $editForm = $this->makeEditForm($request, $view);
            if ($editForm) {
                $this->firstEl = $this->elementsData ? reset($this->elementsData) : null;
                // Checking if the currently open document is stored in the list of "open documents" - if not, add it:
                if ((($this->docDat[1] ?? null) !== $this->storeUrlMd5 || !isset($this->docHandler[$this->storeUrlMd5]))
                    && !$this->dontStoreDocumentRef
                ) {
                    $this->docHandler[$this->storeUrlMd5] = [
                        $this->storeTitle,
                        $this->storeArray,
                        $this->storeUrl,
                        $this->firstEl,
                        $this->returnUrl,
                    ];
                    $this->getBackendUser()->pushModuleData('FormEngine', [$this->docHandler, $this->storeUrlMd5]);
                    BackendUtility::setUpdateSignal('OpendocsController::updateNumber', count($this->docHandler));
                }
                $body .= $this->formResultCompiler->addCssFiles();
                $body .= $this->compileForm($editForm);
                $body .= $this->formResultCompiler->printNeededJSFunctions();
                $body .= '</form>';
            }
        }

        if ($this->firstEl === null) {
            // In case firstEl is null, no edit form could be created. Therefore, add an
            // info box and remove the spinner, since it will never be resolved by FormEngine.
            $view->setUiBlock(false);
            $body .= $this->getInfobox(
                $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:noEditForm.message'),
                $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:noEditForm'),
            );
        }

        // Access check...
        // The page will show only if there is a valid page and if this page may be viewed by the user
        $this->pageinfo = BackendUtility::readPageAccess($this->viewId, $this->perms_clause) ?: [];
        // Setting up the buttons and markers for doc header
        $this->resolveMetaInformation($view);
        $this->getButtons($view, $request);

        // Create language switch options if the record is already persisted, and it is a single record to edit
        if ($this->isSavedRecord && $this->isSingleRecordView()) {
            $this->languageSwitch(
                $view,
                (string)($this->firstEl['table'] ?? ''),
                (int)($this->firstEl['uid'] ?? 0),
                isset($this->firstEl['pid']) ? (int)$this->firstEl['pid'] : null
            );
        }

        return $body;
    }

    protected function resolveMetaInformation(ModuleTemplate $view): void
    {
        $file = null;
        if (($this->firstEl['table'] ?? '') === 'sys_file_metadata' && (int)($this->firstEl['uid'] ?? 0) > 0) {
            $fileUid = (int)(BackendUtility::getRecord('sys_file_metadata', (int)$this->firstEl['uid'], 'file')['file'] ?? 0);
            try {
                $file = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($fileUid);
            } catch (FileDoesNotExistException|InsufficientUserPermissionsException $e) {
                // do nothing when file is not accessible
            }
        }
        if ($file instanceof FileInterface) {
            $view->getDocHeaderComponent()->setMetaInformationForResource($file);
        } elseif ($this->pageinfo !== []) {
            $view->getDocHeaderComponent()->setMetaInformation($this->pageinfo);
        }
    }

    /**
     * Creates the editing form with FormEngine, based on the input from GPvars.
     *
     * @return string HTML form elements wrapped in tables
     */
    protected function makeEditForm(ServerRequestInterface $request, ModuleTemplate $view): string
    {
        // Initialize variables
        $this->elementsData = [];
        $this->errorC = 0;
        $editForm = '';
        $beUser = $this->getBackendUser();
        // Traverse the GPvar edit array tables
        foreach ($this->editconf as $table => $conf) {
            if (!is_array($conf) || !$this->tcaSchemaFactory->has($table)) {
                // Skip for invalid config or in case no TCA exists
                continue;
            }
            if (!$beUser->check('tables_modify', $table)) {
                // Skip in case the user has insufficient permissions and increment the error counter
                $this->errorC++;
                continue;
            }
            // Traverse the keys/comments of each table (keys can be a comma list of uids)
            foreach ($conf as $cKey => $command) {
                if ($command !== 'edit' && $command !== 'new') {
                    // Skip if invalid command
                    continue;
                }
                // Get the ids:
                $ids = GeneralUtility::trimExplode(',', (string)$cKey, true);
                // Traverse the ids:
                foreach ($ids as $theUid) {
                    // Don't save this document title in the document selector if the document is new.
                    if ($command === 'new') {
                        $this->dontStoreDocumentRef = 1;
                    }

                    try {
                        // Reset viewId - it should hold data of last entry only
                        $this->viewId = 0;

                        $formDataCompilerInput = [
                            'request' => $request,
                            'tableName' => $table,
                            'vanillaUid' => (int)$theUid,
                            'command' => $command,
                            'returnUrl' => $this->R_URI,
                        ];
                        if (is_array($this->overrideVals) && is_array($this->overrideVals[$table])) {
                            $formDataCompilerInput['overrideValues'] = $this->overrideVals[$table];
                        }
                        if (!empty($this->defVals) && is_array($this->defVals)) {
                            $formDataCompilerInput['defaultValues'] = $this->defVals;
                        }

                        $formData = $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class));

                        // Set this->viewId if possible
                        if ($command === 'new'
                            && $table !== 'pages'
                            && !empty($formData['parentPageRow']['uid'])
                        ) {
                            $this->viewId = $formData['parentPageRow']['uid'];
                        } else {
                            if ($table === 'pages') {
                                // Only set viewId in case it's not a new page - as this can not be viewed before being saved
                                if ($command !== 'new' && MathUtility::canBeInterpretedAsInteger($formData['databaseRow']['uid'])) {
                                    $this->viewId = (int)$formData['databaseRow']['uid'];
                                }
                            } elseif (!empty($formData['parentPageRow']['uid'])) {
                                $this->viewId = $formData['parentPageRow']['uid'];
                            }
                        }

                        // Determine if delete button can be shown
                        $permission = new Permission($formData['userPermissionOnPage']);
                        if ($formData['tableName'] === 'pages') {
                            $deleteAccess = $permission->get(Permission::PAGE_DELETE);
                        } else {
                            $deleteAccess = $permission->get(Permission::CONTENT_EDIT);
                        }

                        // Display "is-locked" message
                        if ($command === 'edit') {
                            $lockInfo = BackendUtility::isRecordLocked($table, $formData['databaseRow']['uid']);
                            if ($lockInfo) {
                                $view->addFlashMessage($lockInfo['msg'], '', ContextualFeedbackSeverity::WARNING);
                            }
                        }

                        // Record title
                        if (!$this->storeTitle) {
                            $this->storeTitle = htmlspecialchars($this->recTitle ?: ($formData['recordTitle'] ?? ''));
                        }

                        $this->elementsData[] = [
                            'table' => $table,
                            'uid' => $formData['databaseRow']['uid'],
                            'pid' => $formData['databaseRow']['pid'],
                            'cmd' => $command,
                            'deleteAccess' => $deleteAccess,
                        ];

                        if ($command !== 'new') {
                            BackendUtility::lockRecords($table, $formData['databaseRow']['uid'], $table === 'tt_content' ? $formData['databaseRow']['pid'] : 0);
                        }

                        // Set list if only specific fields should be rendered. This will trigger
                        // ListOfFieldsContainer instead of FullRecordContainer in OuterWrapContainer
                        if (!empty($this->columnsOnly[$table])) {
                            $formData['fieldListToRender'] = implode(',', $this->columnsOnly[$table]);
                            if (!empty($this->columnsOnly['__hiddenGeneratorFields'][$table])) {
                                $formData['hiddenFieldListToRender'] = implode(',', $this->columnsOnly['__hiddenGeneratorFields'][$table]);
                            }
                        }

                        $formData['renderType'] = 'outerWrapContainer';
                        $formResult = $this->nodeFactory->create($formData)->render();

                        $html = $formResult['html'];

                        $formResult['html'] = '';
                        $formResult['doSaveFieldName'] = 'doSave';

                        // @todo: Put all the stuff into FormEngine as final "compiler" class
                        // @todo: This is done here for now to not rewrite addCssFiles()
                        // @todo: and printNeededJSFunctions() now
                        $this->formResultCompiler->mergeResult($formResult);

                        // Seems the pid is set as hidden field (again) at end?!
                        if ($command === 'new') {
                            // @todo: looks ugly
                            $html .= LF
                                . '<input type="hidden"'
                                . ' name="data[' . htmlspecialchars($table) . '][' . htmlspecialchars($formData['databaseRow']['uid']) . '][pid]"'
                                . ' value="' . (int)$formData['databaseRow']['pid'] . '" />';
                        }

                        $editForm .= $html;
                    } catch (NoFieldsToRenderException $e) {
                        $this->errorC++;
                        $editForm .= $this->getInfobox(
                            $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:noFieldsEditForm.message'),
                            $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:noFieldsEditForm'),
                        );
                    } catch (AccessDeniedException $e) {
                        $this->errorC++;
                        // Try to fetch error message from "recordInternals" be user object
                        // @todo: This construct should be logged and localized and de-uglified
                        $message = (!empty($beUser->errorMsg)) ? $beUser->errorMsg : $e->getMessage() . ' ' . $e->getCode();
                        $title = $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.noEditPermission');
                        $editForm .= $this->getInfobox($message, $title);
                    } catch (DatabaseRecordException | DatabaseRecordWorkspaceDeletePlaceholderException $e) {
                        $editForm .= $this->getInfobox($e->getMessage());
                    }
                } // End of for each uid
            }
        }
        return $editForm;
    }

    /**
     * Helper function for rendering an Infobox
     */
    protected function getInfobox(string $message, ?string $title = null): string
    {
        return '<div class="callout callout-danger">' .
                '<div class="callout-icon">' .
                    '<span class="icon-emphasized">' .
                        $this->iconFactory->getIcon('actions-close', IconSize::SMALL)->render() .
                    '</span>' .
                '</div>' .
                '<div class="callout-content">' .
                    ($title ? '<div class="callout-title">' . htmlspecialchars($title) . '</div>' : '') .
                    '<div class="callout-body">' . htmlspecialchars($message) . '</div>' .
                '</div>' .
            '</div>';
    }

    /**
     * Create the panel of buttons for submitting the form or otherwise perform operations.
     */
    protected function getButtons(ModuleTemplate $view, ServerRequestInterface $request): void
    {
        $buttonBar = $view->getDocHeaderComponent()->getButtonBar();
        if (!empty($this->firstEl)) {
            $record = BackendUtility::getRecord($this->firstEl['table'], $this->firstEl['uid']);
            $schema = $this->tcaSchemaFactory->get($this->firstEl['table']);
            $languageCapability = $schema->isLanguageAware() ? $schema->getCapability(TcaSchemaCapability::Language) : null;

            $this->setIsSavedRecord();

            $sysLanguageUid = 0;
            if (
                $this->isSavedRecord
                && $schema->isLanguageAware()
                && isset($record[($languageField = $languageCapability->getLanguageField()->getName())])
            ) {
                $sysLanguageUid = (int)$record[$languageField];
            } elseif (isset($this->defVals['sys_language_uid'])) {
                $sysLanguageUid = (int)$this->defVals['sys_language_uid'];
            }

            $l18nParent = $schema->isLanguageAware() && isset($record[($translationOriginPointerField = $languageCapability->getTranslationOriginPointerField()->getName())])
                ? (int)$record[$translationOriginPointerField]
                : 0;

            $this->setIsPageInFreeTranslationMode($record, $sysLanguageUid);

            $this->registerCloseButtonToButtonBar($buttonBar, ButtonBar::BUTTON_POSITION_LEFT, 1);

            // Show buttons when table is not read-only
            if (
                !$this->errorC
                && !$schema->hasCapability(TcaSchemaCapability::AccessReadOnly)
            ) {
                $this->registerSaveButtonToButtonBar($buttonBar, ButtonBar::BUTTON_POSITION_LEFT, 2);
                $this->registerViewButtonToButtonBar($buttonBar, ButtonBar::BUTTON_POSITION_LEFT, 3);
                if ($this->firstEl['cmd'] !== 'new') {
                    $this->registerNewButtonToButtonBar(
                        $buttonBar,
                        ButtonBar::BUTTON_POSITION_LEFT,
                        4,
                        $sysLanguageUid,
                        $l18nParent
                    );
                    $this->registerDuplicationButtonToButtonBar(
                        $buttonBar,
                        ButtonBar::BUTTON_POSITION_LEFT,
                        5,
                        $sysLanguageUid,
                        $l18nParent
                    );
                }
                $this->registerDeleteButtonToButtonBar($buttonBar, ButtonBar::BUTTON_POSITION_LEFT, 6, $request);
                $this->registerColumnsOnlyButtonToButtonBar($buttonBar, ButtonBar::BUTTON_POSITION_LEFT, 7);
                $this->registerHistoryButtonToButtonBar($buttonBar, ButtonBar::BUTTON_POSITION_RIGHT, 1);
            }
        }

        $this->registerInfoButtonToButtonBar($buttonBar, ButtonBar::BUTTON_POSITION_RIGHT, 2);
        $this->registerOpenInNewWindowButtonToButtonBar($buttonBar, ButtonBar::BUTTON_POSITION_RIGHT, 3, $request);
        $this->registerShortcutButtonToButtonBar($buttonBar, ButtonBar::BUTTON_POSITION_RIGHT, 4, $request);
    }

    /**
     * Set the boolean to check if the record is saved
     */
    protected function setIsSavedRecord(): void
    {
        $this->isSavedRecord = (
            $this->firstEl['cmd'] !== 'new'
            && MathUtility::canBeInterpretedAsInteger($this->firstEl['uid'])
        );
    }

    /**
     * Return true if inconsistent language handling is allowed
     */
    protected function isInconsistentLanguageHandlingAllowed(): bool
    {
        $allowInconsistentLanguageHandling = BackendUtility::getPagesTSconfig(
            $this->pageinfo['uid'] ?? 0
        )['mod']['web_layout']['allowInconsistentLanguageHandling'] ?? ['value' => '0'];

        return $allowInconsistentLanguageHandling['value'] === '1';
    }

    /**
     * Set the boolean to check if the page is in free translation mode
     */
    protected function setIsPageInFreeTranslationMode(?array $record, int $sysLanguageUid): void
    {
        if ($this->firstEl['table'] === 'tt_content') {
            if (!$this->isSavedRecord) {
                $this->isPageInFreeTranslationMode = $this->getFreeTranslationMode(
                    (int)($this->pageinfo['uid'] ?? 0),
                    (int)($this->defVals['colPos'] ?? 0),
                    $sysLanguageUid
                );
            } else {
                $this->isPageInFreeTranslationMode = $this->getFreeTranslationMode(
                    (int)($this->pageinfo['uid'] ?? 0),
                    (int)($record['colPos'] ?? 0),
                    $sysLanguageUid
                );
            }
        }
    }

    /**
     * True if the page is in free translation mode.
     */
    protected function getFreeTranslationMode(int $page, int $column, int $language): bool
    {
        $freeTranslationMode = false;
        if ($this->getConnectedContentElementTranslationsCount($page, $column, $language) === 0
            && $this->getStandAloneContentElementTranslationsCount($page, $column, $language) >= 0
        ) {
            $freeTranslationMode = true;
        }
        return $freeTranslationMode;
    }

    /**
     * Register the close button to the button bar
     */
    protected function registerCloseButtonToButtonBar(ButtonBar $buttonBar, string $position, int $group): void
    {
        $closeButton = $buttonBar->makeLinkButton()
            ->setHref('#')
            ->setClasses('t3js-editform-close')
            ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.closeDoc'))
            ->setShowLabelText(true)
            ->setIcon($this->iconFactory->getIcon('actions-close', IconSize::SMALL))
            ->setDisabled(true);
        $buttonBar->addButton($closeButton, $position, $group);
    }

    /**
     * Register the save button to the button bar
     */
    protected function registerSaveButtonToButtonBar(ButtonBar $buttonBar, string $position, int $group): void
    {
        $saveButton = $buttonBar->makeInputButton()
            ->setForm('EditDocumentController')
            ->setIcon($this->iconFactory->getIcon('actions-document-save', IconSize::SMALL))
            ->setName('_savedok')
            ->setShowLabelText(true)
            ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.saveDoc'))
            ->setValue('1')
            ->setDisabled(true);
        $buttonBar->addButton($saveButton, $position, $group);
    }

    /**
     * Register the view button to the button bar
     */
    protected function registerViewButtonToButtonBar(ButtonBar $buttonBar, string $position, int $group): void
    {
        if ($this->viewId // Pid to show the record
            && !$this->noView // Passed parameter
            && !empty($this->firstEl['table']) // No table
            // @TODO: TsConfig option should change to viewDoc
            && $this->getTsConfigOption($this->firstEl['table'], 'saveDocView')
        ) {
            $previewUriBuilderForCurrentPage = PreviewUriBuilder::create($this->pageinfo)->isPreviewable();
            $previewUriBuilder = $this->getPreviewUriBuilderForRecordPreview();
            if ($previewUriBuilderForCurrentPage || $previewUriBuilder->isPreviewable()) {
                $previewUrl = $previewUriBuilder->buildUri();
                if ($previewUrl) {
                    $viewButton = $buttonBar->makeLinkButton()
                        ->setHref((string)$previewUrl)
                        ->setIcon($this->iconFactory->getIcon('actions-view', IconSize::SMALL))
                        ->setShowLabelText(true)
                        ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.viewDoc'))
                        ->setClasses('t3js-editform-view')
                        ->setDisabled(true);
                    if (!$this->isSavedRecord && $this->firstEl['table'] === 'pages') {
                        $viewButton->setDataAttributes(['is-new' => '']);
                    }
                    $buttonBar->addButton($viewButton, $position, $group);
                }
            }
        }
    }

    /**
     * Register the new button to the button bar
     */
    protected function registerNewButtonToButtonBar(ButtonBar $buttonBar, string $position, int $group, int $sysLanguageUid, int $l18nParent): void
    {
        if ($this->firstEl['table'] !== 'sys_file_metadata'
            && !empty($this->firstEl['table'])
            && (
                (
                    (
                        $this->isInconsistentLanguageHandlingAllowed()
                        || $this->isPageInFreeTranslationMode
                    )
                    && $this->firstEl['table'] === 'tt_content'
                )
                || (
                    $this->firstEl['table'] !== 'tt_content'
                    && (
                        $sysLanguageUid === 0
                        || $l18nParent === 0
                    )
                )
            )
            && $this->getTsConfigOption($this->firstEl['table'], 'saveDocNew')
        ) {
            $newButton = $buttonBar->makeLinkButton()
                ->setHref('#')
                ->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL))
                ->setShowLabelText(true)
                ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.newDoc'))
                ->setClasses('t3js-editform-new')
                ->setDisabled(true);
            if (!$this->isSavedRecord) {
                $newButton->setDataAttributes(['is-new' => '']);
            }
            $buttonBar->addButton($newButton, $position, $group);
        }
    }

    /**
     * Register the duplication button to the button bar
     */
    protected function registerDuplicationButtonToButtonBar(ButtonBar $buttonBar, string $position, int $group, int $sysLanguageUid, int $l18nParent): void
    {
        if ($this->firstEl['table'] !== 'sys_file_metadata'
            && !empty($this->firstEl['table'])
            && (
                (
                    (
                        $this->isInconsistentLanguageHandlingAllowed()
                        || $this->isPageInFreeTranslationMode
                    )
                    && $this->firstEl['table'] === 'tt_content'
                )
                || (
                    $this->firstEl['table'] !== 'tt_content'
                    && (
                        $sysLanguageUid === 0
                        || $l18nParent === 0
                    )
                )
            )
            && $this->getTsConfigOption($this->firstEl['table'], 'showDuplicate')
            && $this->isSingleRecordView()
        ) {
            $duplicateButton = $buttonBar->makeLinkButton()
                ->setHref('#')
                ->setShowLabelText(true)
                ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.duplicateDoc'))
                ->setIcon($this->iconFactory->getIcon('actions-document-duplicates-select', IconSize::SMALL))
                ->setClasses('t3js-editform-duplicate')
                ->setDisabled(true);
            if (!$this->isSavedRecord) {
                $duplicateButton->setDataAttributes(['is-new' => '']);
            }
            $buttonBar->addButton($duplicateButton, $position, $group);
        }
    }

    /**
     * Register the delete button to the button bar
     */
    protected function registerDeleteButtonToButtonBar(ButtonBar $buttonBar, string $position, int $group, ServerRequestInterface $request): void
    {
        if ($this->firstEl['deleteAccess']
            && $this->isSavedRecord
            && !$this->getDisableDelete()
            && !$this->isRecordCurrentBackendUser()
            && $this->isSingleRecordView()
        ) {
            $returnUrl = $this->retUrl;
            if ($this->firstEl['table'] === 'pages') {
                // The below is a hack to replace the return url with an url to the current module on id=0. Otherwise,
                // this might lead to empty views, since the current id is the page, which is about to be deleted.
                $parsedUrl = parse_url($returnUrl);
                $routePath = str_replace($this->backendEntryPointResolver->getPathFromRequest($request), '', $parsedUrl['path'] ?? '');
                parse_str($parsedUrl['query'] ?? '', $queryParams);
                if ($routePath
                    && isset($queryParams['id'])
                    && (string)$this->firstEl['uid'] === (string)$queryParams['id']
                ) {
                    try {
                        // TODO: Use the page's pid instead of 0, this requires a clean API to manipulate the page
                        // tree from the outside to be able to mark the pid as active
                        $returnUrl = (string)$this->uriBuilder->buildUriFromRoutePath($routePath, ['id' => 0]);
                    } catch (ResourceNotFoundException $e) {
                        // Resolved path can not be matched to a configured route
                    }
                }
            }

            $referenceIndex = GeneralUtility::makeInstance(ReferenceIndex::class);
            $numberOfReferences = $referenceIndex->getNumberOfReferencedRecords(
                $this->firstEl['table'],
                (int)$this->firstEl['uid']
            );
            $referenceCountMessage = BackendUtility::referenceCount(
                $this->firstEl['table'],
                (int)$this->firstEl['uid'],
                $this->getLanguageService()->sL(
                    'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.referencesToRecord'
                ),
                (string)$numberOfReferences
            );
            $translationCountMessage = BackendUtility::translationCount(
                $this->firstEl['table'],
                (string)(int)$this->firstEl['uid'],
                $this->getLanguageService()->sL(
                    'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.translationsOfRecord'
                )
            );

            $deleteUrl = (string)$this->uriBuilder->buildUriFromRoute('tce_db', [
                'cmd' => [
                    $this->firstEl['table'] => [
                        $this->firstEl['uid'] => [
                            'delete' => '1',
                        ],
                    ],
                ],
                'redirect' => $returnUrl,
            ]);

            $recordInfo = $this->storeTitle;
            if ($this->getBackendUser()->shallDisplayDebugInformation()) {
                $recordInfo .= ' [' . $this->firstEl['table'] . ':' . $this->firstEl['uid'] . ']';
            }

            $deleteButton = $buttonBar->makeLinkButton()
                ->setClasses('t3js-editform-delete-record')
                ->setDataAttributes([
                    'uid' => $this->firstEl['uid'],
                    'table' => $this->firstEl['table'],
                    'record-info' => trim($recordInfo),
                    'reference-count-message' => $referenceCountMessage,
                    'translation-count-message' => $translationCountMessage,
                ])
                ->setHref($deleteUrl)
                ->setIcon($this->iconFactory->getIcon('actions-edit-delete', IconSize::SMALL))
                ->setShowLabelText(true)
                ->setTitle($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:deleteItem'))
                ->setDisabled(true);
            $buttonBar->addButton($deleteButton, $position, $group);
        }
    }

    /**
     * Register the info button to the button bar
     */
    protected function registerInfoButtonToButtonBar(ButtonBar $buttonBar, string $position, int $group): void
    {
        if ($this->isSingleRecordView()
            && !empty($this->firstEl['table'])
            && $this->isSavedRecord
        ) {
            $button = GeneralUtility::makeInstance(GenericButton::class);
            $button->setLabel($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:showInfo'));
            $button->setAttributes([
                'type' => 'button',
                'data-dispatch-action' => 'TYPO3.InfoWindow.showItem',
                'data-dispatch-args-list' => $this->firstEl['table'] . ',' . $this->firstEl['uid'],
                'disabled' => 'disabled',
            ]);
            $button->setIcon($this->iconFactory->getIcon('actions-document-info', IconSize::SMALL));
            $buttonBar->addButton($button, $position, $group);
        }
    }

    /**
     * Register the history button to the button bar
     */
    protected function registerHistoryButtonToButtonBar(ButtonBar $buttonBar, string $position, int $group): void
    {
        $userTsConfig = $this->getBackendUser()->getTSConfig();
        if ($this->isSingleRecordView()
            && !empty($this->firstEl['table'])
            && (bool)trim($userTsConfig['options.']['showHistory.'][$this->firstEl['table']] ?? $userTsConfig['options.']['showHistory'] ?? '1')
        ) {
            $historyUrl = (string)$this->uriBuilder->buildUriFromRoute('record_history', [
                'element' => $this->firstEl['table'] . ':' . $this->firstEl['uid'],
                'returnUrl' => $this->R_URI,
            ]);
            $historyButton = $buttonBar->makeLinkButton()
                ->setHref($historyUrl)
                ->setTitle($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:recordHistory'))
                ->setIcon($this->iconFactory->getIcon('actions-document-history-open', IconSize::SMALL))
                ->setDisabled(true);
            $buttonBar->addButton($historyButton, $position, $group);
        }
    }

    /**
     * Register the columns only button to the button bar
     */
    protected function registerColumnsOnlyButtonToButtonBar(ButtonBar $buttonBar, string $position, int $group): void
    {
        if ($this->columnsOnly
            && $this->isSingleRecordView()
        ) {
            $columnsOnlyButton = $buttonBar->makeLinkButton()
                ->setHref($this->R_URI . '&columnsOnly=')
                ->setTitle($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:editWholeRecord'))
                ->setShowLabelText(true)
                ->setIcon($this->iconFactory->getIcon('actions-open', IconSize::SMALL))
                ->setDisabled(true);

            $buttonBar->addButton($columnsOnlyButton, $position, $group);
        }
    }

    /**
     * Register the open in new window button to the button bar
     */
    protected function registerOpenInNewWindowButtonToButtonBar(ButtonBar $buttonBar, string $position, int $group, ServerRequestInterface $request): void
    {
        $closeUrl = $this->getCloseUrl();
        if ($this->returnUrl !== $closeUrl) {
            // Generate a URL to the current edit form
            $arguments = $this->getUrlQueryParamsForCurrentRequest($request);
            $arguments['returnUrl'] = $closeUrl;
            $requestUri = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $arguments);
            $openInNewWindowButton = $buttonBar
                ->makeLinkButton()
                ->setHref('#')
                ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.openInNewWindow'))
                ->setIcon($this->iconFactory->getIcon('actions-window-open', IconSize::SMALL))
                ->setDataAttributes([
                    'dispatch-action' => 'TYPO3.WindowManager.localOpen',
                    'dispatch-args' => GeneralUtility::jsonEncodeForHtmlAttribute([
                        $requestUri,
                        true, // switchFocus
                        md5($this->R_URI), // windowName,
                        'width=670,height=500,status=0,menubar=0,scrollbars=1,resizable=1', // windowFeatures
                    ]),
                ])
                ->setDisabled(true);
            $buttonBar->addButton($openInNewWindowButton, $position, $group);
        }
    }

    /**
     * Register the shortcut button to the button bar
     */
    protected function registerShortcutButtonToButtonBar(ButtonBar $buttonBar, string $position, int $group, ServerRequestInterface $request): void
    {
        if ($this->returnUrl !== $this->getCloseUrl()) {
            $arguments = $this->getUrlQueryParamsForCurrentRequest($request);
            $shortCutButton = $buttonBar->makeShortcutButton()
                ->setRouteIdentifier('record_edit')
                ->setDisplayName($this->getShortcutTitle($request))
                ->setArguments($arguments)
                ->setDisabled(true);
            $buttonBar->addButton($shortCutButton, $position, $group);
        }
    }

    protected function getUrlQueryParamsForCurrentRequest(ServerRequestInterface $request): array
    {
        $queryParams = $request->getQueryParams();
        $potentialArguments = [
            'edit',
            'defVals',
            'overrideVals',
            'columnsOnly',
            'returnNewPageId',
            'noView',
        ];
        $arguments = [];
        foreach ($potentialArguments as $argument) {
            if (!empty($queryParams[$argument])) {
                $arguments[$argument] = $queryParams[$argument];
            }
        }
        return $arguments;
    }

    /**
     * Get the count of connected translated content elements
     */
    protected function getConnectedContentElementTranslationsCount(int $page, int $column, int $language): int
    {
        $queryBuilder = $this->getQueryBuilderForTranslationMode($page, $column, $language);
        return (int)$queryBuilder
            ->andWhere(
                $queryBuilder->expr()->gt(
                    $this->tcaSchemaFactory->get('tt_content')->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName(),
                    $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
                )
            )
            ->executeQuery()
            ->fetchOne();
    }

    /**
     * Get the count of standalone translated content elements
     */
    protected function getStandAloneContentElementTranslationsCount(int $page, int $column, int $language): int
    {
        $queryBuilder = $this->getQueryBuilderForTranslationMode($page, $column, $language);
        return (int)$queryBuilder
            ->andWhere(
                $queryBuilder->expr()->eq(
                    $this->tcaSchemaFactory->get('tt_content')->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName(),
                    $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
                )
            )
            ->executeQuery()
            ->fetchOne();
    }

    /**
     * Get the query builder for the translation mode
     */
    protected function getQueryBuilderForTranslationMode(int $page, int $column, int $language): QueryBuilder
    {
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
        $queryBuilder->getRestrictions()
            ->removeAll()
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
            ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace));
        return $queryBuilder
            ->count('uid')
            ->from('tt_content')
            ->where(
                $queryBuilder->expr()->eq(
                    'pid',
                    $queryBuilder->createNamedParameter($page, Connection::PARAM_INT)
                ),
                $queryBuilder->expr()->eq(
                    $this->tcaSchemaFactory->get('tt_content')->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(),
                    $queryBuilder->createNamedParameter($language, Connection::PARAM_INT)
                ),
                $queryBuilder->expr()->eq(
                    'colPos',
                    $queryBuilder->createNamedParameter($column, Connection::PARAM_INT)
                )
            );
    }

    /**
     * Put together the various elements (buttons, selectors, form) into a table
     *
     * @param string $editForm HTML form.
     * @return string Composite HTML
     */
    protected function compileForm(string $editForm): string
    {
        $formContent = '
            <form
                action="' . htmlspecialchars($this->R_URI) . '"
                method="post"
                enctype="multipart/form-data"
                name="editform"
                id="EditDocumentController"
            >
            ' . $editForm . '
            <input type="hidden" name="returnUrl" value="' . htmlspecialchars($this->retUrl) . '" />
            <input type="hidden" name="popViewId" value="' . htmlspecialchars((string)$this->viewId) . '" />
            <input type="hidden" name="closeDoc" value="0" />
            <input type="hidden" name="doSave" value="0" />';
        if ($this->returnNewPageId) {
            $formContent .= '<input type="hidden" name="returnNewPageId" value="1" />';
        }
        return $formContent;
    }

    /**
     * Update expanded/collapsed states on new inline records if any within backendUser->uc.
     *
     * @param array|null $uc The uc array to be processed and saved - uc[inlineView][...]
     * @param DataHandler $dataHandler Instance of DataHandler that saved data before
     */
    protected function updateInlineView(?array $uc, DataHandler $dataHandler): void
    {
        $backendUser = $this->getBackendUser();
        if (!isset($uc['inlineView']) || !is_array($uc['inlineView'])) {
            return;
        }
        $inlineView = (array)json_decode(is_string($backendUser->uc['inlineView'] ?? false) ? $backendUser->uc['inlineView'] : '', true);
        foreach ($uc['inlineView'] as $topTable => $topRecords) {
            foreach ($topRecords as $topUid => $childElements) {
                foreach ($childElements as $childTable => $childRecords) {
                    $uids = array_keys($dataHandler->substNEWwithIDs_table, $childTable);
                    if (!empty($uids)) {
                        $newExpandedChildren = [];
                        foreach ($childRecords as $childUid => $state) {
                            if ($state && in_array($childUid, $uids)) {
                                $newChildUid = $dataHandler->substNEWwithIDs[$childUid];
                                $newExpandedChildren[] = $newChildUid;
                            }
                        }
                        // Add new expanded child records to UC (if any):
                        if (!empty($newExpandedChildren)) {
                            $inlineViewCurrent = &$inlineView[$topTable][$topUid][$childTable];
                            if (is_array($inlineViewCurrent)) {
                                $inlineViewCurrent = array_unique(array_merge($inlineViewCurrent, $newExpandedChildren));
                            } else {
                                $inlineViewCurrent = $newExpandedChildren;
                            }
                        }
                    }
                }
            }
        }
        $backendUser->uc['inlineView'] = json_encode($inlineView);
        $backendUser->writeUC();
    }

    /**
     * Returns if delete for the current table is disabled by configuration.
     * For sys_file_metadata in default language delete is always disabled.
     */
    protected function getDisableDelete(): bool
    {
        $disableDelete = false;
        if ($this->firstEl['table'] === 'sys_file_metadata') {
            $row = BackendUtility::getRecord('sys_file_metadata', $this->firstEl['uid'], 'sys_language_uid');
            if ((int)($row['sys_language_uid'] ?? 0) === 0) {
                // Always disable for default language
                $disableDelete = true;
            }
        } else {
            $disableDelete = (bool)$this->getTsConfigOption($this->firstEl['table'] ?? '', 'disableDelete');
        }
        return $disableDelete;
    }

    /**
     * Return true in case the current record is the current backend user
     */
    protected function isRecordCurrentBackendUser(): bool
    {
        $backendUser = $this->getBackendUser();
        return $this->firstEl['table'] === 'be_users' && (int)($this->firstEl['uid'] ?? 0) === $backendUser->getUserId();
    }

    /**
     * Returns the URL (usually for the "returnUrl") which closes the current window.
     * Used when editing a record in a popup.
     */
    protected function getCloseUrl(): string
    {
        return PathUtility::getPublicResourceWebPath('EXT:backend/Resources/Public/Html/Close.html');
    }

    /**
     * Make selector box for creating new translation for a record or switching to edit the record
     * in an existing language. Displays only languages which are available for the current page.
     *
     * @param string $table Table name
     * @param int $uid Uid for which to create a new language
     * @param int|null $pid Pid of the record
     */
    protected function languageSwitch(ModuleTemplate $view, string $table, int $uid, ?int $pid = null)
    {
        $backendUser = $this->getBackendUser();
        if (!$this->tcaSchemaFactory->has($table)) {
            return;
        }
        $schema = $this->tcaSchemaFactory->get($table);
        if (!$schema->isLanguageAware()) {
            return;
        }
        $languageCapability = $schema->getCapability(TcaSchemaCapability::Language);
        $languageField = $languageCapability->getLanguageField()->getName();
        $transOrigPointerField = $languageCapability->getTranslationOriginPointerField()->getName();

        if (!$backendUser->check('tables_modify', $table)) {
            return;
        }

        if ($pid === null) {
            $row = BackendUtility::getRecord($table, $uid, 'pid');
            $pid = $row['pid'];
        }
        // Get all available languages for the page
        // If editing a page, the translations of the current UID need to be fetched
        if ($table === 'pages') {
            $row = BackendUtility::getRecord($table, $uid, $transOrigPointerField);
            // Ensure the check is always done against the default language page
            $availableLanguages = $this->getLanguages(
                (int)$row[$transOrigPointerField] ?: $uid,
                $table
            );
        } else {
            $availableLanguages = $this->getLanguages((int)$pid, $table);
        }
        // Remove default language, if user does not have access. This is necessary, since
        // the default language is always added when fetching the system languages (#88504).
        if (isset($availableLanguages[0]) && !$this->getBackendUser()->checkLanguageAccess(0)) {
            unset($availableLanguages[0]);
        }
        // Page available in other languages than default language?
        if (count($availableLanguages) > 1) {
            $rowsByLang = [];
            $fetchFields = 'uid,' . $languageField . ',' . $transOrigPointerField;
            // Get record in current language
            $rowCurrent = BackendUtility::getLiveVersionOfRecord($table, $uid, $fetchFields);
            if (!is_array($rowCurrent)) {
                $rowCurrent = BackendUtility::getRecord($table, $uid, $fetchFields);
            }
            $currentLanguage = (int)$rowCurrent[$languageField];
            // Disabled for records with [all] language!
            if ($currentLanguage > -1) {
                // Get record in default language if needed
                if ($currentLanguage && $rowCurrent[$transOrigPointerField]) {
                    $rowsByLang[0] = BackendUtility::getLiveVersionOfRecord(
                        $table,
                        $rowCurrent[$transOrigPointerField],
                        $fetchFields
                    );
                    if (!is_array($rowsByLang[0])) {
                        $rowsByLang[0] = BackendUtility::getRecord(
                            $table,
                            $rowCurrent[$transOrigPointerField],
                            $fetchFields
                        );
                    }
                } else {
                    $rowsByLang[$rowCurrent[$languageField]] = $rowCurrent;
                }
                // List of language id's that should not be added to the selector
                $noAddOption = [];
                if ($rowCurrent[$transOrigPointerField] || $currentLanguage === 0) {
                    // Get record in other languages to see what's already available
                    $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
                    $queryBuilder->getRestrictions()
                        ->removeAll()
                        ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
                        ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $backendUser->workspace));
                    $result = $queryBuilder->select(...GeneralUtility::trimExplode(',', $fetchFields, true))
                        ->from($table)
                        ->where(
                            $queryBuilder->expr()->eq(
                                'pid',
                                $queryBuilder->createNamedParameter($pid, Connection::PARAM_INT)
                            ),
                            $queryBuilder->expr()->gt(
                                $languageField,
                                $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
                            ),
                            $queryBuilder->expr()->eq(
                                $transOrigPointerField,
                                $queryBuilder->createNamedParameter($rowsByLang[0]['uid'], Connection::PARAM_INT)
                            )
                        )
                        ->executeQuery();
                    while ($row = $result->fetchAssociative()) {
                        if ($backendUser->workspace !== 0 && $schema->isWorkspaceAware()) {
                            $workspaceVersion = BackendUtility::getWorkspaceVersionOfRecord($backendUser->workspace, $table, $row['uid'], 'uid,t3ver_state');
                            if (!empty($workspaceVersion)) {
                                $versionState = VersionState::tryFrom($workspaceVersion['t3ver_state'] ?? 0);
                                if ($versionState === VersionState::DELETE_PLACEHOLDER) {
                                    // If a workspace delete placeholder exists for this translation: Mark
                                    // this language as "don't add to selector" and continue with next row,
                                    // otherwise an edit link to a delete placeholder would be created, which
                                    // does not make sense.
                                    $noAddOption[] = (int)$row[$languageField];
                                    continue;
                                }
                            }
                        }
                        $rowsByLang[$row[$languageField]] = $row;
                    }
                }
                $languageMenu = $view->getDocHeaderComponent()->getMenuRegistry()->makeMenu();
                $languageMenu->setIdentifier('_langSelector');
                $languageMenu->setLabel(
                    $this->getLanguageService()->sL(
                        'LLL:EXT:backend/Resources/Private/Language/locallang.xlf:editdocument.moduleMenu.dropdown.label'
                    )
                );
                foreach ($availableLanguages as $languageId => $language) {
                    $selectorOptionLabel = $language['title'];
                    // Create url for creating a localized record
                    $addOption = true;
                    $href = '';
                    if (!isset($rowsByLang[$languageId])) {
                        // Translation in this language does not exist
                        if ($this->columnsOnly[$table] ?? false) {
                            // Don't add option since we are in a view with just a subset of fields, those views
                            // are specific editing fields only views and are not meant for translation handling.
                            $addOption = false;
                        } elseif (!isset($rowsByLang[0]['uid'])) {
                            // Don't add option since no default row to localize from exists
                            // TODO: Actually tt_content is able to localize from another l10n_source then L=0.
                            //       This however is currently only possible via the translation wizard.
                            $addOption = false;
                        } else {
                            // Build the link to add the localization
                            $selectorOptionLabel .= ' [' . htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.new')) . ']';
                            $href = (string)$this->uriBuilder->buildUriFromRoute(
                                'tce_db',
                                [
                                    'cmd' => [
                                        $table => [
                                            $rowsByLang[0]['uid'] => [
                                                'localize' => $languageId,
                                            ],
                                        ],
                                    ],
                                    'redirect' => (string)$this->uriBuilder->buildUriFromRoute(
                                        'record_edit',
                                        [
                                            'justLocalized' => $table . ':' . $rowsByLang[0]['uid'] . ':' . $languageId,
                                            'returnUrl' => $this->retUrl,
                                        ]
                                    ),
                                ]
                            );
                        }
                    } else {
                        $params = [
                            'edit[' . $table . '][' . $rowsByLang[$languageId]['uid'] . ']' => 'edit',
                            'returnUrl' => $this->retUrl,
                        ];
                        if ($this->columnsOnly[$table] ?? false) {
                            $params['columnsOnly'] = [$table => $this->columnsOnly[$table]];
                        }
                        if ($table === 'pages') {
                            // Disallow manual adjustment of the language field for pages
                            $params['overrideVals'] = [
                                'pages' => [
                                    'sys_language_uid' => $languageId,
                                ],
                            ];
                        }
                        $href = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $params);
                    }
                    if ($addOption && !in_array($languageId, $noAddOption, true)) {
                        $menuItem = $languageMenu->makeMenuItem()
                            ->setTitle($selectorOptionLabel)
                            ->setHref($href);
                        if ($languageId === $currentLanguage) {
                            $menuItem->setActive(true);
                        }
                        $languageMenu->addMenuItem($menuItem);
                    }
                }
                $view->getDocHeaderComponent()->getMenuRegistry()->addMenu($languageMenu);
            }
        }
    }

    /**
     * Redirects to FormEngine with new parameters to edit a just created localized record.
     */
    protected function localizationRedirect(ServerRequestInterface $request): ?ResponseInterface
    {
        $justLocalized = $request->getQueryParams()['justLocalized'] ?? null;
        if (empty($justLocalized)) {
            return null;
        }

        [$table, $origUid, $language] = explode(':', $justLocalized);

        if (!$this->tcaSchemaFactory->has($table)) {
            return null;
        }
        $schema = $this->tcaSchemaFactory->get($table);
        if (!$schema->isLanguageAware()) {
            return null;
        }
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
        $queryBuilder->getRestrictions()
            ->removeAll()
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
            ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace));
        $localizedRecord = $queryBuilder->select('uid')
            ->from($table)
            ->where(
                $queryBuilder->expr()->eq(
                    $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName(),
                    $queryBuilder->createNamedParameter($language, Connection::PARAM_INT)
                ),
                $queryBuilder->expr()->eq(
                    $schema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName(),
                    $queryBuilder->createNamedParameter($origUid, Connection::PARAM_INT)
                )
            )
            ->executeQuery()
            ->fetchAssociative();
        if (is_array($localizedRecord)) {
            // Create redirect response to self to edit just created record
            $returnUrl = $request->getParsedBody()['returnUrl'] ?? $request->getQueryParams()['returnUrl'] ?? '';
            return new RedirectResponse(
                (string)$this->uriBuilder->buildUriFromRoute(
                    'record_edit',
                    [
                        'edit[' . $table . '][' . $localizedRecord['uid'] . ']' => 'edit',
                        'returnUrl' => GeneralUtility::sanitizeLocalUrl($returnUrl),
                    ]
                ),
                303
            );
        }
        return null;
    }

    /**
     * Returns languages available for record translations on given page.
     *
     * @param int $id Page id: If zero, all available system languages will be returned. If set to
     *                another value, only languages, a page translation exists for, will be returned.
     * @param string $table For pages we want all languages, for other records the languages of the page translations
     * @return array Array with languages (uid, title, ISOcode, flagIcon)
     */
    protected function getLanguages(int $id, string $table): array
    {
        // This usually happens when a non-pages record is added after another, so we are fetching the proper page ID
        if ($id < 0 && $table !== 'pages') {
            $pageId = $this->pageinfo['uid'] ?? null;
            if ($pageId !== null) {
                $pageId = (int)$pageId;
            } else {
                $fullRecord = BackendUtility::getRecord($table, abs($id));
                $pageId = (int)$fullRecord['pid'];
            }
        } else {
            if ($table === 'pages' && $id > 0) {
                $fullRecord = BackendUtility::getRecordWSOL('pages', $id);
                $id = (int)($fullRecord['t3ver_oid'] ?: $fullRecord['uid']);
            }
            $pageId = $id;
        }
        // Fetch the current translations of this page, to only show the ones where there is a page translation
        $allLanguages = array_filter(
            GeneralUtility::makeInstance(TranslationConfigurationProvider::class)->getSystemLanguages($pageId),
            static fn(array $language): bool => (int)$language['uid'] !== -1
        );
        if ($table !== 'pages' && $id > 0) {
            $schema = $this->tcaSchemaFactory->get('pages');
            $languageCapability = $schema->getCapability(TcaSchemaCapability::Language);
            $languageField = $languageCapability->getLanguageField()->getName();
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
            $queryBuilder->getRestrictions()->removeAll()
                ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
                ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace));
            $statement = $queryBuilder->select('uid', $languageField)
                ->from('pages')
                ->where(
                    $queryBuilder->expr()->eq(
                        $languageCapability->getTranslationOriginPointerField()->getName(),
                        $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)
                    )
                )
                ->executeQuery();
            $availableLanguages = [];
            if ($allLanguages[0] ?? false) {
                $availableLanguages = [
                    0 => $allLanguages[0],
                ];
            }
            while ($row = $statement->fetchAssociative()) {
                $languageId = (int)$row[$languageField];
                if (isset($allLanguages[$languageId])) {
                    $availableLanguages[$languageId] = $allLanguages[$languageId];
                }
            }
            return $availableLanguages;
        }
        return $allLanguages;
    }

    /**
     * Fix $this->editconf if versioning applies to any of the records
     *
     * @param array|null $mapArray Mapping between old and new ids if auto-versioning has been performed.
     */
    protected function fixWSversioningInEditConf(?array $mapArray = null): void
    {
        if (!is_array($this->editconf)) {
            return;
        }
        foreach ($this->editconf as $table => $conf) {
            if (is_array($conf) && $this->tcaSchemaFactory->has($table)) {
                // Traverse the keys/comments of each table (keys can be a comma list of uids)
                $newConf = [];
                foreach ($conf as $cKey => $cmd) {
                    if ($cmd === 'edit') {
                        // Traverse the ids:
                        $ids = GeneralUtility::trimExplode(',', (string)$cKey, true);
                        foreach ($ids as $idKey => $theUid) {
                            if (is_array($mapArray)) {
                                if ($mapArray[$table][$theUid] ?? false) {
                                    $ids[$idKey] = $mapArray[$table][$theUid];
                                }
                            } else {
                                // Default, look for versions in workspace for record:
                                $calcPRec = $this->getRecordForEdit((string)$table, (int)$theUid);
                                if (is_array($calcPRec)) {
                                    // Setting UID again if it had changed, eg. due to workspace versioning.
                                    $ids[$idKey] = $calcPRec['uid'];
                                }
                            }
                        }
                        // Add the possibly manipulated IDs to the new-build newConf array:
                        $newConf[implode(',', $ids)] = $cmd;
                    } else {
                        $newConf[$cKey] = $cmd;
                    }
                }
                // Store the new conf array:
                $this->editconf[$table] = $newConf;
            }
        }
    }

    /**
     * Get record for editing.
     *
     * @param string $table Table name
     * @param int $theUid Record UID
     * @return array|false Returns record to edit, false if none
     */
    protected function getRecordForEdit(string $table, int $theUid): array|bool
    {
        $schema = $this->tcaSchemaFactory->get($table);
        // Fetch requested record:
        $reqRecord = BackendUtility::getRecord($table, $theUid, 'uid,pid' . ($schema->isWorkspaceAware() ? ',t3ver_oid' : ''));
        if (is_array($reqRecord)) {
            // If workspace is OFFLINE:
            if ($this->getBackendUser()->workspace !== 0) {
                // Check for versioning support of the table:
                if ($schema->isWorkspaceAware()) {
                    // If the record is already a version of "something" pass it by.
                    if ($reqRecord['t3ver_oid'] > 0 || VersionState::tryFrom($reqRecord['t3ver_state'] ?? 0) === VersionState::NEW_PLACEHOLDER) {
                        // (If it turns out not to be a version of the current workspace there will be trouble, but
                        // that is handled inside DataHandler then and in the interface it would clearly be an error of
                        // links if the user accesses such a scenario)
                        return $reqRecord;
                    }
                    // The input record was online and an offline version must be found or made:
                    // Look for version of this workspace:
                    $versionRec = BackendUtility::getWorkspaceVersionOfRecord(
                        $this->getBackendUser()->workspace,
                        $table,
                        $reqRecord['uid'],
                        'uid,pid,t3ver_oid'
                    );
                    return is_array($versionRec) ? $versionRec : $reqRecord;
                }
                // This means that editing cannot occur on this record because it was not supporting versioning
                // which is required inside an offline workspace.
                return false;
            }
            // In ONLINE workspace, just return the originally requested record:
            return $reqRecord;
        }
        // Return FALSE because the table/uid was not found anyway.
        return false;
    }

    /**
     * Populates the variables $this->storeArray, $this->storeUrl, $this->storeUrlMd5
     * to prepare 'open documents' urls
     */
    protected function compileStoreData(ServerRequestInterface $request): void
    {
        $queryParams = $request->getQueryParams();
        $parsedBody = $request->getParsedBody();

        foreach (['edit', 'defVals', 'overrideVals' , 'columnsOnly' , 'noView'] as $key) {
            if (isset($this->R_URL_getvars[$key])) {
                $this->storeArray[$key] = $this->R_URL_getvars[$key];
            } else {
                $this->storeArray[$key] = $parsedBody[$key] ?? $queryParams[$key] ?? null;
            }
        }

        $this->storeUrl = HttpUtility::buildQueryString($this->storeArray, '&');
        $this->storeUrlMd5 = md5($this->storeUrl);
    }

    /**
     * Get a TSConfig 'option.' array, possibly for a specific table.
     */
    protected function getTsConfigOption(string $table, string $key): string
    {
        return trim((string)(
            $this->getBackendUser()->getTSConfig()['options.'][$key . '.'][$table]
            ?? $this->getBackendUser()->getTSConfig()['options.'][$key]
            ?? ''
        ));
    }

    /**
     * Handling the closing of a document
     * The argument $mode can be one of this values:
     * - 0/1 will redirect to $this->retUrl [self::DOCUMENT_CLOSE_MODE_DEFAULT || self::DOCUMENT_CLOSE_MODE_REDIRECT]
     * - 3 will clear the docHandler (thus closing all documents) [self::DOCUMENT_CLOSE_MODE_CLEAR_ALL]
     * - 4 will do no redirect [self::DOCUMENT_CLOSE_MODE_NO_REDIRECT]
     * - other values will call setDocument with ->retUrl
     *
     * @param int $mode the close mode: one of self::DOCUMENT_CLOSE_MODE_*
     * @param ServerRequestInterface $request Incoming request
     * @return ResponseInterface|null Redirect response if needed
     */
    protected function closeDocument(int $mode, ServerRequestInterface $request): ?ResponseInterface
    {
        $setupArr = [];
        // If current document is found in docHandler,
        // then unset it, possibly unset it ALL and finally, write it to the session data
        if (isset($this->docHandler[$this->storeUrlMd5])) {
            // add the closing document to the recent documents
            $recentDocs = $this->getBackendUser()->getModuleData('opendocs::recent');
            if (!is_array($recentDocs)) {
                $recentDocs = [];
            }
            $closedDoc = $this->docHandler[$this->storeUrlMd5];
            $recentDocs = array_merge([$this->storeUrlMd5 => $closedDoc], $recentDocs);
            if (count($recentDocs) > 8) {
                $recentDocs = array_slice($recentDocs, 0, 8);
            }
            // remove it from the list of the open documents
            unset($this->docHandler[$this->storeUrlMd5]);
            if ($mode === self::DOCUMENT_CLOSE_MODE_CLEAR_ALL) {
                $recentDocs = array_merge($this->docHandler, $recentDocs);
                $this->docHandler = [];
            }
            $this->getBackendUser()->pushModuleData('opendocs::recent', $recentDocs);
            $this->getBackendUser()->pushModuleData('FormEngine', [$this->docHandler, $this->docDat[1]]);
            BackendUtility::setUpdateSignal('OpendocsController::updateNumber', count($this->docHandler));
        }
        if ($mode === self::DOCUMENT_CLOSE_MODE_NO_REDIRECT) {
            return null;
        }
        // If ->returnEditConf is set, then add the current content of editconf to the ->retUrl variable: used by
        // other scripts, like wizard_add, to know which records was created or so...
        if ($this->returnEditConf && $this->retUrl != (string)$this->uriBuilder->buildUriFromRoute('dummy')) {
            $this->retUrl .= '&returnEditConf=' . rawurlencode((string)json_encode($this->editconf));
        }
        // If mode is NOT set (means 0) OR set to 1, then make a header location redirect to $this->retUrl
        if ($mode === self::DOCUMENT_CLOSE_MODE_DEFAULT || $mode === self::DOCUMENT_CLOSE_MODE_REDIRECT) {
            return new RedirectResponse($this->retUrl, 303);
        }
        if ($this->retUrl === '') {
            return null;
        }
        $retUrl = (string)$this->returnUrl;
        if (is_array($this->docHandler) && !empty($this->docHandler)) {
            if (!empty($setupArr[2])) {
                $sParts = parse_url($request->getAttribute('normalizedParams')->getRequestUri());
                $retUrl = $sParts['path'] . '?' . $setupArr[2] . '&returnUrl=' . rawurlencode($retUrl);
            }
        }
        return new RedirectResponse($retUrl, 303);
    }

    /**
     * Returns the shortcut title for the current element
     */
    protected function getShortcutTitle(ServerRequestInterface $request): string
    {
        $queryParameters = $request->getQueryParams();
        $languageService = $this->getLanguageService();
        $defaultTitle = $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.edit');

        if (!is_array($queryParameters['edit'] ?? false)) {
            return $defaultTitle;
        }

        // @todo There may be a more efficient way in using FormEngine FormData.
        // @todo Therefore, the button initialization however has to take place at a later stage.

        $table = (string)key($queryParameters['edit']);
        $schema = $this->tcaSchemaFactory->has($table) ? $this->tcaSchemaFactory->get($table) : null;
        $tableTitle = $schema?->getTitle($languageService->sL(...)) ?: $table;
        $identifier = (string)key($queryParameters['edit'][$table]);
        $action = (string)($queryParameters['edit'][$table][$identifier] ?? '');

        if ($action === 'new') {
            if ($table === 'pages') {
                return sprintf(
                    $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.createNewPage'),
                    $tableTitle
                );
            }

            $identifier = (int)$identifier;
            if ($identifier === 0) {
                return sprintf(
                    $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.createNewRecordRootLevel'),
                    $tableTitle
                );
            }

            $pageRecord = null;
            if ($identifier < 0) {
                $parentRecord = BackendUtility::getRecord($table, abs($identifier));
                if ($parentRecord['pid'] ?? false) {
                    $pageRecord = BackendUtility::getRecord('pages', (int)($parentRecord['pid']), 'title');
                }
            } else {
                $pageRecord = BackendUtility::getRecord('pages', $identifier, 'title');
            }

            if ($pageRecord !== null) {
                $pageTitle = BackendUtility::getRecordTitle('pages', $pageRecord);
                if ($pageTitle !== '') {
                    return sprintf(
                        $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.createNewRecord'),
                        $tableTitle,
                        $pageTitle
                    );
                }
            }

            return $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.createNew') . ' ' . $tableTitle;
        }

        if ($action === 'edit') {
            if ($multiple = str_contains($identifier, ',')) {
                // Multiple records are given, use the first one for further evaluation of e.g. the parent page
                $recordId = (int)(GeneralUtility::trimExplode(',', $identifier, true)[0] ?? 0);
            } else {
                $recordId = (int)$identifier;
            }
            $record = BackendUtility::getRecord($table, $recordId) ?? [];
            $recordTitle = BackendUtility::getRecordTitle($table, $record);
            if ($table === 'pages') {
                return $multiple
                    ? $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.editMultiplePages')
                    : sprintf($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.editPage'), $tableTitle, $recordTitle);
            }
            if (!isset($record['pid'])) {
                return $defaultTitle;
            }
            $pageId = (int)$record['pid'];
            if ($pageId === 0) {
                return $multiple
                    ? sprintf($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.editMultipleRecordsRootLevel'), $tableTitle)
                    : sprintf($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.editRecordRootLevel'), $tableTitle, $recordTitle);
            }
            $pageRow = BackendUtility::getRecord('pages', $pageId) ?? [];
            $pageTitle = BackendUtility::getRecordTitle('pages', $pageRow);
            if ($multiple) {
                return sprintf($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.editMultipleRecords'), $tableTitle, $pageTitle);
            }
            if ($recordTitle !== '') {
                return sprintf($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.editRecord'), $tableTitle, $recordTitle, $pageTitle);
            }
            return sprintf($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.editRecordNoTitle'), $tableTitle, $pageTitle);
        }

        return $defaultTitle;
    }

    protected function resolveDefaultReturnUrl(): string
    {
        $module = $this->moduleProvider->getFirstAccessibleModule($this->getBackendUser());
        $routeName = $module ? $module->getIdentifier() : 'dummy';
        return (string)$this->uriBuilder->buildUriFromRoute($routeName);
    }

    /**
     * Whether a single record view is requested. This
     * means, only one element exists in $elementsData.
     */
    protected function isSingleRecordView(): bool
    {
        return count($this->elementsData) === 1;
    }

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

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