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

use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\DataHandling\PageDoktypeRegistry;
use TYPO3\CMS\Core\DataHandling\TableColumnType;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Resource\DefaultUploadFolderResolver;
use TYPO3\CMS\Core\Resource\Exception;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Resource\Security\FileNameValidator;
use TYPO3\CMS\Core\Schema\Capability\RootLevelCapability;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\DiffUtility;
use TYPO3\CMS\Core\Utility\File\ExtendedFileUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;

/**
 * T3D file Import / Export library (TYPO3 Record Document)
 *
 * @internal This class is not considered part of the public TYPO3 API.
 */
abstract class ImportExport
{
    /**
     * Whether "import" or "export" mode of object.
     *
     * @var string
     */
    protected string $mode = '';

    /**
     * Root page of import or export page tree
     */
    protected int $pid = -1;

    /**
     * Root page record of import or of export page tree
     */
    protected ?array $pidRecord = null;

    /**
     * If set, static relations (not exported) will be shown in preview as well
     */
    protected bool $showStaticRelations = false;

    /**
     * Updates all records that has same UID instead of creating new!
     */
    protected bool $update = false;

    /**
     * Set by importData() when an import is started.
     */
    protected bool $doesImport = false;

    /**
     * Setting the import mode for specific import records.
     * Available options are: force_uid, as_new, exclude, ignore_pid, respect_pid
     */
    protected array $importMode = [];

    /**
     * If set, PID correct is ignored globally
     */
    protected bool $globalIgnorePid = false;

    /**
     * If set, all UID values are forced! (update or import)
     */
    protected bool $forceAllUids = false;

    /**
     * If set, a diff-view column is added to the preview.
     */
    protected bool $showDiff = false;

    /**
     * Array of values to substitute in editable soft references.
     */
    protected array $softrefInputValues = [];

    /**
     * Add tables names here which should not be exported with the file.
     * (Where relations should be mapped to same UIDs in target system).
     */
    protected array $relStaticTables = [];

    /**
     * Exclude map. Keys are table:uid pairs and if set, records are not added to the export.
     */
    protected array $excludeMap = [];

    /**
     * Soft reference token ID modes.
     */
    protected array $softrefCfg = [];

    /**
     * Listing extension dependencies.
     */
    protected array $extensionDependencies = [];

    /**
     * After records are written this array is filled with [table][original_uid] = [new_uid]
     */
    protected array $importMapId = [];

    /**
     * Error log.
     */
    protected array $errorLog = [];

    /**
     * Cache for record paths
     */
    protected array $cacheGetRecordPath = [];

    /**
     * Internal import/export memory
     */
    protected array $dat = [];

    /**
     * File processing object
     */
    protected ?ExtendedFileUtility $fileProcObj = null;
    protected array $remainHeader = [];
    protected ?string $temporaryFolderName = null;

    protected ?Folder $defaultImportExportFolder = null;
    /**
     * Flag to control whether all disabled records and their children are excluded (true) or included (false). Defaults
     * to the old behaviour of including everything.
     */
    protected bool $excludeDisabledRecords = false;

    protected IconFactory $iconFactory;
    protected TcaSchemaFactory $tcaSchemaFactory;
    protected FileNameValidator $fileNameValidator;
    protected PageDoktypeRegistry $pageDoktypeRegistry;
    protected DefaultUploadFolderResolver $defaultUploadFolderResolver;
    protected ResourceFactory $resourceFactory;
    protected DiffUtility $diffUtility;

    public function injectIconFactory(IconFactory $iconFactory): void
    {
        $this->iconFactory = $iconFactory;
    }

    public function injectTcaSchemaFactory(TcaSchemaFactory $tcaSchemaFactory): void
    {
        $this->tcaSchemaFactory = $tcaSchemaFactory;
    }

    public function injectFileNameValidator(FileNameValidator $fileNameValidator): void
    {
        $this->fileNameValidator = $fileNameValidator;
    }

    public function injectPageDoktypeRegistry(PageDoktypeRegistry $pageDoktypeRegistry): void
    {
        $this->pageDoktypeRegistry = $pageDoktypeRegistry;
    }

    public function injectDefaultUploadFolderResolver(DefaultUploadFolderResolver $defaultUploadFolderResolver): void
    {
        $this->defaultUploadFolderResolver = $defaultUploadFolderResolver;
    }

    public function injectResourceFactory(ResourceFactory $resourceFactory): void
    {
        $this->resourceFactory = $resourceFactory;
    }

    public function injectDiffUtility(DiffUtility $diffUtility): void
    {
        $this->diffUtility = $diffUtility;
    }

    /**
     * Displays a preview of the import or export.
     *
     * @return array The preview data
     */
    public function renderPreview(): array
    {
        $previewData = [
            'update' => $this->update,
            'showDiff' => $this->showDiff,
            'insidePageTree' => [],
            'outsidePageTree' => [],
        ];

        if (!isset($this->dat['header']['pagetree']) && !isset($this->dat['header']['records'])) {
            return $previewData;
        }

        // Traverse header:
        $this->remainHeader = $this->dat['header'];

        // Preview of the page tree to be exported
        if (is_array($this->dat['header']['pagetree'] ?? null)) {
            $this->traversePageTree($this->dat['header']['pagetree'], $previewData['insidePageTree']);
            foreach ($previewData['insidePageTree'] as &$line) {
                $line['controls'] = $this->renderControls($line);
                $line['message'] = (!empty($line['msg']) && !$this->doesImport ? '<span class="text-danger">' . htmlspecialchars($line['msg']) . '</span>' : '');
            }
        }

        // Preview the remaining records that were not included in the page tree
        if (is_array($this->remainHeader['records'] ?? null)) {
            if (is_array($this->remainHeader['records']['pages'] ?? null)) {
                $this->traversePageRecords($this->remainHeader['records']['pages'], $previewData['outsidePageTree']);
            }
            $this->traverseAllRecords($this->remainHeader['records'], $previewData['outsidePageTree']);
            foreach ($previewData['outsidePageTree'] as &$line) {
                $line['controls'] = $this->renderControls($line);
                $line['message'] = (!empty($line['msg']) && !$this->doesImport ? '<span class="text-danger">' . htmlspecialchars($line['msg']) . '</span>' : '');
            }
        }

        return $previewData;
    }

    /**
     * Go through page tree for display
     *
     * @param array<int, array> $pageTree Page tree array with uid/subrow (from ->dat[header][pagetree])
     * @param array $lines Output lines array
     * @param int $indent Indentation level
     */
    protected function traversePageTree(array $pageTree, array &$lines, int $indent = 0): void
    {
        foreach ($pageTree as $pageUid => $page) {
            if ($this->excludeDisabledRecords === true && $this->isRecordDisabled('pages', $pageUid)) {
                $this->excludePageAndRecords($pageUid, $page);
                continue;
            }

            // Add page
            $this->addRecord('pages', $pageUid, $lines, $indent);

            // Add records
            foreach ($this->dat['header']['pid_lookup'][$pageUid] ?? [] as $table => $records) {
                $table = (string)$table;
                if ($table !== 'pages') {
                    foreach (array_keys($records) as $uid) {
                        $this->addRecord($table, (int)$uid, $lines, $indent + 1);
                    }
                }
            }
            unset($this->remainHeader['pid_lookup'][$pageUid]);

            // Add subtree
            if (is_array($page['subrow'] ?? null)) {
                $this->traversePageTree($page['subrow'], $lines, $indent + 1);
            }
        }
    }

    /**
     * Test whether a record is disabled (e.g. hidden)
     *
     * @param string $table Name of the records' database table
     * @param int $uid Database uid of the record
     * @return bool true if the record is disabled, false otherwise
     */
    protected function isRecordDisabled(string $table, int $uid): bool
    {
        if (!$this->tcaSchemaFactory->has($table)) {
            return false;
        }
        $schema = $this->tcaSchemaFactory->get($table);
        if (!$schema->hasCapability(TcaSchemaCapability::RestrictionDisabledField)) {
            return false;
        }
        $disabledFieldName = $schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName();
        return (bool)($this->dat['records'][$table . ':' . $uid]['data'][$disabledFieldName] ?? false);
    }

    /**
     * Exclude a page, its sub pages (recursively) and records placed in them from this import/export
     *
     * @param int $pageUid Uid of the page to exclude
     * @param array $page Page array with uid/subrow (from ->dat[header][pagetree])
     */
    protected function excludePageAndRecords(int $pageUid, array $page): void
    {
        // Exclude page
        unset($this->remainHeader['records']['pages'][$pageUid]);

        // Exclude records
        foreach ($this->dat['header']['pid_lookup'][$pageUid] ?? [] as $table => $records) {
            if ($table !== 'pages') {
                foreach (array_keys($records) as $uid) {
                    unset($this->remainHeader['records'][$table][$uid]);
                }
            }
        }
        unset($this->remainHeader['pid_lookup'][$pageUid]);

        // Exclude subtree
        foreach ($page['subrow'] ?? [] as $subPageUid => $subPage) {
            $this->excludePageAndRecords($subPageUid, $subPage);
        }
    }

    /**
     * Go through remaining pages (not in tree)
     *
     * @param array<int, array> $pageTree Page tree array with uid/subrow (from ->dat[header][pagetree])
     * @param array $lines Output lines array
     */
    protected function traversePageRecords(array $pageTree, array &$lines): void
    {
        foreach ($pageTree as $pageUid => $_) {
            // Add page
            $this->addRecord('pages', (int)$pageUid, $lines, 0, true);

            // Add records
            foreach ($this->dat['header']['pid_lookup'][$pageUid] ?? [] as $table => $records) {
                if ($table !== 'pages') {
                    foreach (array_keys($records) as $uid) {
                        $this->addRecord((string)$table, (int)$uid, $lines, 2);
                    }
                }
            }
            unset($this->remainHeader['pid_lookup'][$pageUid]);
        }
    }

    /**
     * Go through ALL records (if the pages are displayed first, those will not be among these!)
     *
     * @param array $pageTree Page tree array with uid/subrow (from ->dat[header][pagetree])
     * @param array $lines Output lines array
     */
    protected function traverseAllRecords(array $pageTree, array &$lines): void
    {
        foreach ($pageTree as $table => $records) {
            $this->addGeneralErrorsByTable($table);
            if ($table !== 'pages') {
                foreach (array_keys($records) as $uid) {
                    $this->addRecord((string)$table, (int)$uid, $lines, 0, true);
                }
            }
        }
    }

    /**
     * Log general error message for a given table
     *
     * @param string $table database table name
     */
    protected function addGeneralErrorsByTable(string $table): void
    {
        if ($this->update && $table === 'sys_file') {
            $this->addError('Updating sys_file records is not supported! They will be imported as new records!');
        }
        if ($this->forceAllUids && $table === 'sys_file') {
            $this->addError('Forcing uids of sys_file records is not supported! They will be imported as new records!');
        }
    }

    /**
     * Add a record, its relations and soft references, to the preview
     *
     * @param string $table Table name
     * @param int $uid Record uid
     * @param array $lines Output lines array
     * @param int $indent Indentation level
     * @param bool $checkImportInPidRecord If you want import validation, you can set this so it checks if the import can take place on the specified page.
     */
    protected function addRecord(string $table, int $uid, array &$lines, int $indent, bool $checkImportInPidRecord = false): void
    {
        $record = $this->dat['header']['records'][$table][$uid] ?? null;
        unset($this->remainHeader['records'][$table][$uid]);
        if (!is_array($record) && !($table === 'pages' && $uid === 0)) {
            $this->addError('MISSING RECORD: ' . $table . ':' . $uid);
        }

        // Create record information for preview
        $line = [];
        $line['ref'] = $table . ':' . $uid;
        $line['type'] = 'record';
        $line['msg'] = '';
        if ($table === '_SOFTREF_') {
            // Record is a soft reference
            $line['preCode'] = $this->renderIndent($indent);
            $line['title'] = '<em>' . htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_softReferencesFiles')) . '</em>';
        } elseif (!$this->tcaSchemaFactory->has($table)) {
            // Record is of unknown table
            $line['preCode'] = $this->renderIndent($indent);
            $line['title'] = '<em>' . htmlspecialchars((string)$record['title']) . '</em>';
            $line['msg'] = 'UNKNOWN TABLE "' . $line['ref'] . '"';
        } else {
            $pidRecord = $this->getPidRecord();
            $line['preCode'] = ''
                . $this->renderIndent($indent)
                . $this->iconFactory
                    ->getIconForRecord(
                        $table,
                        (array)($this->dat['records'][$table . ':' . $uid]['data'] ?? []),
                        IconSize::SMALL
                    )
                    ->setTitle($line['ref'])
                    ->render();
            $line['title'] = htmlspecialchars($record['title'] ?? '');
            // Link to page view
            if ($table === 'pages') {
                $viewID = $this->mode === 'export' ? $uid : ($this->doesImport ? ($this->importMapId['pages'][$uid] ?? 0) : 0);
                if ($viewID) {
                    $attributes = PreviewUriBuilder::create($viewID)->serializeDispatcherAttributes();
                    if ($attributes) {
                        $line['title'] = sprintf('<a href="#" %s>%s</a>', $attributes, $line['title']);
                    }
                }
            }
            $line['active'] = !$this->isRecordDisabled($table, $uid) ? 'active' : 'hidden';
            if ($this->mode === 'import' && $pidRecord !== null) {
                $schema = $this->tcaSchemaFactory->get($table);
                $rootLevelCapability = $schema->getCapability(TcaSchemaCapability::RestrictionRootLevel);
                if ($checkImportInPidRecord) {
                    if (!$this->getBackendUser()->doesUserHaveAccess($pidRecord, ($table === 'pages' ? 8 : 16))) {
                        $line['msg'] .= '"' . $line['ref'] . '" cannot be INSERTED on this page! ';
                    }
                    if ($this->pid > 0
                        && !$this->pageDoktypeRegistry->isRecordTypeAllowedForDoktype($table, $pidRecord['doktype'])
                        && !$rootLevelCapability->getRootLevelType()
                    ) {
                        $line['msg'] .= '"' . $table . '" cannot be INSERTED on this page type (change page type to "Folder".) ';
                    }
                }
                if (!$this->getBackendUser()->check('tables_modify', $table)) {
                    $line['msg'] .= 'You are not allowed to CREATE "' . $table . '" tables! ';
                }
                if ($schema->hasCapability(TcaSchemaCapability::AccessReadOnly)) {
                    $line['msg'] .= 'TABLE "' . $table . '" is READ ONLY! ';
                }
                if ($schema->hasCapability(TcaSchemaCapability::AccessAdminOnly) && !$this->getBackendUser()->isAdmin()) {
                    $line['msg'] .= 'TABLE "' . $table . '" is ADMIN ONLY! ';
                }
                if ($rootLevelCapability->getRootLevelType() === RootLevelCapability::TYPE_ONLY_ON_ROOTLEVEL) {
                    $line['msg'] .= 'TABLE "' . $table . '" will be inserted on ROOT LEVEL! ';
                }
                $databaseRecord = null;
                if ($this->update) {
                    $databaseRecord = $this->getRecordFromDatabase($table, $uid, $this->showDiff ? '*' : 'uid,pid');
                    if ($databaseRecord === null) {
                        $line['updatePath'] = '<strong>NEW!</strong>';
                    } else {
                        $line['updatePath'] = htmlspecialchars($this->getRecordPath((int)($databaseRecord['pid'] ?? 0)));
                    }
                    if ($table === 'sys_file') {
                        $line['updateMode'] = '';
                    } else {
                        $line['updateMode'] = $this->renderImportModeSelector(
                            $table,
                            $uid,
                            $databaseRecord !== null
                        );
                    }
                }
                // Diff view
                if ($this->showDiff) {
                    $diffInverse = $this->update;
                    // For imports, get new id:
                    if (isset($this->importMapId[$table][$uid]) && $newUid = $this->importMapId[$table][$uid]) {
                        $diffInverse = false;
                        $databaseRecord = $this->getRecordFromDatabase($table, $newUid, '*');
                        BackendUtility::workspaceOL($table, $databaseRecord);
                    }
                    /** @var array|null $importRecord */
                    $importRecord = $this->dat['records'][$table . ':' . $uid]['data'] ?? null;
                    if ($databaseRecord === null) {
                        $line['showDiffContent'] = '';
                    } elseif (is_array($databaseRecord) && is_array($importRecord)) {
                        $line['showDiffContent'] = $this->compareRecords($databaseRecord, $importRecord, $table, $diffInverse);
                    } else {
                        $line['showDiffContent'] = 'ERROR: One of the inputs were not an array!';
                    }
                }
            }
        }
        $lines[] = $line;

        // Database relations
        if (is_array($record['rels'] ?? null)) {
            $this->addRelations($record['rels'], $lines, $indent);
        }
        // Soft references
        if (is_array($record['softrefs'] ?? null)) {
            $this->addSoftRefs($record['softrefs'], $lines, $indent);
        }
    }

    /**
     * Add database relations of a record to the preview
     *
     * @param array $relations Array of relations
     * @param array $lines Output lines array
     * @param int $indent Indentation level
     * @param array $recursionCheck Recursion check stack
     */
    protected function addRelations(array $relations, array &$lines, int $indent, array $recursionCheck = []): void
    {
        foreach ($relations as $relation) {
            $table = $relation['table'];
            $uid = $relation['id'];
            $line = [];
            $line['ref'] = $table . ':' . $uid;
            $line['type'] = 'rel';
            $line['msg'] = '';
            if (in_array($line['ref'], $recursionCheck, true)) {
                continue;
            }
            $iconName = 'status-status-checked';
            $staticFixed = false;
            $record = null;
            if ($uid > 0) {
                $record = $this->dat['header']['records'][$table][$uid] ?? null;
                if (!is_array($record)) {
                    if ($this->isTableStatic($table) || $this->isRecordExcluded($table, (int)$uid)
                        || ($relation['tokenID'] ?? '') && !$this->isSoftRefIncluded($relation['tokenID'] ?? '')) {
                        $line['title'] = htmlspecialchars('STATIC: ' . $line['ref']);
                        $staticFixed = true;
                    } else {
                        $databaseRecord = $this->getRecordFromDatabase($table, (int)$uid);
                        $recordPath = $this->getRecordPath($databaseRecord === null ? 0 : ($table === 'pages' ? (int)$databaseRecord['uid'] : (int)$databaseRecord['pid']));
                        $line['title'] = sprintf(
                            '<span title="%s">%s</span>',
                            htmlspecialchars($recordPath),
                            htmlspecialchars($line['ref'])
                        );
                        $line['msg'] = 'LOST RELATION' . ($databaseRecord === null ? ' (Record not found!)' : ' (Path: ' . $recordPath . ')');
                        $iconName = 'status-dialog-warning';
                    }
                } else {
                    $recordPath = $this->getRecordPath($table === 'pages' ? (int)$record['uid'] : (int)$record['pid']);
                    $line['title'] = sprintf(
                        '<span title="%s">%s</span>',
                        htmlspecialchars($recordPath),
                        htmlspecialchars((string)$record['title'])
                    );
                }
            } else {
                // Negative values in relation fields. These are typically fields of e.g. fe_users.
                // They are static values. They CAN theoretically be negative pointers to uids in other tables,
                // but this is so rarely used that it is not supported.
                $line['title'] = htmlspecialchars('FIXED: ' . $line['ref']);
                $staticFixed = true;
            }

            $line['preCode'] = ''
                . $this->renderIndent($indent + 1)
                . $this->iconFactory
                    ->getIcon($iconName, IconSize::SMALL)
                    ->setTitle($line['ref'])
                    ->render();
            if (!$staticFixed || $this->showStaticRelations) {
                $lines[] = $line;
                if (is_array($record) && is_array($record['rels'] ?? null)) {
                    $this->addRelations($record['rels'], $lines, $indent + 1, array_merge($recursionCheck, [$line['ref']]));
                }
            }
        }
    }

    /**
     * Add soft references of a record to the preview
     *
     * @param array $softrefs Soft references
     * @param array $lines Output lines array
     * @param int $indent Indentation level
     */
    protected function addSoftRefs(array $softrefs, array &$lines, int $indent): void
    {
        $languageService = $this->getLanguageService();
        foreach ($softrefs as $softref) {
            $line = [];
            $line['ref'] = 'SOFTREF';
            $line['type'] = 'softref';
            $line['msg'] = '';
            $line['preCode'] = ''
                . $this->renderIndent($indent)
                . $this->iconFactory
                    ->getIcon('status-reference-soft', IconSize::SMALL)
                    ->setTitle($line['ref'])
                    ->render();
            $line['title'] = sprintf(
                '<em>%s, "%s"</em>: <span title="%s">%s</span>',
                $softref['field'],
                $softref['spKey'],
                htmlspecialchars($softref['matchString'] ?? ''),
                htmlspecialchars(GeneralUtility::fixed_lgd_cs($softref['matchString'] ?? '', 60))
            );
            if ($softref['subst']['type'] ?? false) {
                if ($softref['subst']['title'] ?? false) {
                    $line['title'] .= sprintf(
                        '<br>%s <strong>%s</strong> %s',
                        $this->renderIndent($indent + 1),
                        htmlspecialchars($languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_title')),
                        htmlspecialchars(GeneralUtility::fixed_lgd_cs($softref['subst']['title'], 60))
                    );
                }
                if ($softref['subst']['description'] ?? false) {
                    $line['title'] .= sprintf(
                        '<br>%s <strong>%s</strong> %s',
                        $this->renderIndent($indent + 1),
                        htmlspecialchars($languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_descr')),
                        htmlspecialchars(GeneralUtility::fixed_lgd_cs($softref['subst']['description'], 60))
                    );
                }
                if ($softref['subst']['type'] === 'db') {
                    $line['title'] .= sprintf(
                        '<br>%s <strong>%s</strong> %s',
                        $this->renderIndent($indent + 1),
                        htmlspecialchars($languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_softrefsel_record')),
                        $softref['subst']['recordRef']
                    );
                } elseif ($softref['subst']['type'] === 'string') {
                    $line['title'] .= sprintf(
                        '<br>%s <strong>%s</strong> %s',
                        $this->renderIndent($indent + 1),
                        htmlspecialchars($languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_value')),
                        $softref['subst']['tokenValue']
                    );
                }
            }
            $line['_softRefInfo'] = $softref;
            $tokenID = (string)($softref['subst']['tokenID'] ?? '');
            $mode = $tokenID === '' ? '' : ($this->softrefCfg[$tokenID]['mode'] ?? '');
            if (isset($softref['error']) && $mode !== Import::SOFTREF_IMPORT_MODE_EDITABLE && $mode !== Import::SOFTREF_IMPORT_MODE_EXCLUDE) {
                $line['msg'] .= $softref['error'];
            }
            $lines[] = $line;

            // Add database relations
            if (($softref['subst']['type'] ?? '') === 'db') {
                [$referencedTable, $referencedUid] = explode(':', $softref['subst']['recordRef']);
                $relations = [['table' => $referencedTable, 'id' => $referencedUid, 'tokenID' => $tokenID]];
                $this->addRelations($relations, $lines, $indent + 1);
            }
        }
    }

    protected function renderIndent(int $indent): string
    {
        return $indent > 0 ? '<span class="indent indent-inline-block" style="--indent-level: ' . $indent . '"></span>' : '';
    }

    /**
     * Render input controls for import or export
     *
     * @param array $line Output line array
     * @return string HTML
     */
    protected function renderControls(array $line): string
    {
        if ($this->mode === 'export') {
            if ($line['type'] === 'record') {
                return $this->renderRecordExcludeCheckbox($line['ref']);
            }
            if ($line['type'] === 'softref') {
                return $this->renderSoftRefExportSelector($line['_softRefInfo']);
            }
        } elseif ($this->mode === 'import') {
            if ($line['type'] === 'softref') {
                return $this->renderSoftRefImportTextField($line['_softRefInfo']);
            }
        }
        return '';
    }

    /**
     * Render check box for exclusion of a record from export.
     *
     * @param string $recordRef The record ID of the form [table]:[id].
     * @return string HTML
     */
    protected function renderRecordExcludeCheckbox(string $recordRef): string
    {
        return
            '<div class="form-check mb-0">'
            . '<input class="form-check-input t3js-exclude-checkbox" type="checkbox" name="tx_impexp[exclude][' . $recordRef . ']" id="checkExclude' . $recordRef . '" value="1" />'
            . '<label class="form-check-label" for="checkExclude' . $recordRef . '">'
            . htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_exclude'))
            . '</label>'
            . '</div>';
    }

    /**
     * Render text field when importing a soft reference.
     *
     * @param array $softref Soft reference
     * @return string HTML
     */
    protected function renderSoftRefImportTextField(array $softref): string
    {
        if (isset($softref['subst']['tokenID'])) {
            $tokenID = $softref['subst']['tokenID'];
            $cfg = $this->softrefCfg[$tokenID] ?? [];
            if (($cfg['mode'] ?? '') === Import::SOFTREF_IMPORT_MODE_EDITABLE) {
                $html = '';
                if ($cfg['title'] ?? false) {
                    $html .= '<strong>' . htmlspecialchars((string)$cfg['title']) . '</strong><br>';
                }
                $html .= htmlspecialchars((string)$cfg['description']) . '<br>';
                $html .= sprintf(
                    '<input type="text" name="tx_impexp[softrefInputValues][%s]" value="%s" />',
                    $tokenID,
                    htmlspecialchars($this->softrefInputValues[$tokenID] ?? $cfg['defValue'])
                );
                return $html;
            }
        }

        return '';
    }

    /**
     * Render select box with export options for soft references.
     * An export box is shown only if a substitution scheme is found for the soft reference.
     *
     * @param array $softref Soft reference
     * @return string HTML
     */
    protected function renderSoftRefExportSelector(array $softref): string
    {
        $languageService = $this->getLanguageService();
        // Substitution scheme has to be around.
        if (isset($softref['subst']['tokenID'])) {
            $options = [];
            $options[''] = '';
            $options[Import::SOFTREF_IMPORT_MODE_EDITABLE] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_softrefsel_editable');
            $options[Import::SOFTREF_IMPORT_MODE_EXCLUDE] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_softrefsel_exclude');
            $value = $this->softrefCfg[$softref['subst']['tokenID']]['mode'] ?? '';
            $selectHtml = $this->renderSelectBox(
                'tx_impexp[softrefCfg][' . $softref['subst']['tokenID'] . '][mode]',
                $value,
                $options
            );
            $textFieldHtml = '';
            if ($value === Import::SOFTREF_IMPORT_MODE_EDITABLE) {
                if ($softref['subst']['title'] ?? false) {
                    $textFieldHtml .= sprintf(
                        '
                        <input type="hidden" name="tx_impexp[softrefCfg][%1$s][title]" value="%2$s" />
                        <strong>%2$s</strong><br>',
                        $softref['subst']['tokenID'],
                        htmlspecialchars($softref['subst']['title'])
                    );
                }
                if (!($softref['subst']['description'] ?? false)) {
                    $textFieldHtml .= sprintf(
                        '
                        %s<br>
                        <input type="text" name="tx_impexp[softrefCfg][%s][description]" value="%s" />',
                        htmlspecialchars($languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_printerror_description')),
                        $softref['subst']['tokenID'],
                        htmlspecialchars($this->softrefCfg[$softref['subst']['tokenID']]['description'] ?? '')
                    );
                } else {
                    $textFieldHtml .= sprintf(
                        '
                        <input type="hidden" name="tx_impexp[softrefCfg][%1$s][description]" value="%2$s" />%2$s',
                        $softref['subst']['tokenID'],
                        htmlspecialchars($softref['subst']['description'])
                    );
                }
                $textFieldHtml .= sprintf(
                    '
                    <input type="hidden" name="tx_impexp[softrefCfg][%s][defValue]" value="%s" />',
                    $softref['subst']['tokenID'],
                    htmlspecialchars($softref['subst']['tokenValue'])
                );
            }
            return $selectHtml . $textFieldHtml;
        }
        return '';
    }

    /**
     * Render select box with import options for the record.
     *
     * @param string $table Table name
     * @param int $uid Record UID
     * @param bool $doesRecordExist Is there already a record with this UID in the database?
     * @return string HTML
     */
    protected function renderImportModeSelector(string $table, int $uid, bool $doesRecordExist): string
    {
        $languageService = $this->getLanguageService();
        $options = [];
        if (!$doesRecordExist) {
            $options[] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_insert');
            if ($this->getBackendUser()->isAdmin()) {
                $options[Import::IMPORT_MODE_FORCE_UID] = sprintf($languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_forceUidSAdmin'), $uid);
            }
        } else {
            $options[] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_update');
            $options[Import::IMPORT_MODE_AS_NEW] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_importAsNew');
            if (!$this->globalIgnorePid) {
                $options[Import::IMPORT_MODE_IGNORE_PID] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_ignorePid');
            } else {
                $options[Import::IMPORT_MODE_RESPECT_PID] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_respectPid');
            }
        }
        $options[Import::IMPORT_MODE_EXCLUDE] = $languageService->sL('LLL:EXT:impexp/Resources/Private/Language/locallang.xlf:impexpcore_singlereco_exclude');
        return $this->renderSelectBox(
            'tx_impexp[import_mode][' . $table . ':' . $uid . ']',
            (string)($this->importMode[$table . ':' . $uid] ?? ''),
            $options
        );
    }

    /**
     * Renders a select box from option values.
     *
     * @param string $name Form element name
     * @param string $value Current value
     * @param array $options Options to display (key/value pairs)
     * @return string HTML
     */
    protected function renderSelectBox(string $name, string $value, array $options): string
    {
        $optionsHtml = '';
        $isValueInOptions = false;

        foreach ($options as $k => $v) {
            if ((string)$k === $value) {
                $isValueInOptions = true;
                $selectedHtml = ' selected="selected"';
            } else {
                $selectedHtml = '';
            }
            $optionsHtml .= sprintf(
                '<option value="%s"%s>%s</option>',
                htmlspecialchars((string)$k),
                $selectedHtml,
                htmlspecialchars((string)$v)
            );
        }

        // Append and select the current value as an option of the form "[value]"
        // if it is not available in the options.
        if (!$isValueInOptions && $value !== '') {
            $optionsHtml .= sprintf(
                '<option value="%s" selected="selected">%s</option>',
                htmlspecialchars($value),
                htmlspecialchars('[\'' . $value . '\']')
            );
        }

        return '<select class="form-select form-select-sm" name="' . $name . '" style="width: 100px">' . $optionsHtml . '</select>';
    }

    public function getOrCreateTemporaryFolderName(): string
    {
        if (empty($this->temporaryFolderName)) {
            $this->temporaryFolderName = $this->createTemporaryFolderName();
        }
        return $this->temporaryFolderName;
    }

    protected function createTemporaryFolderName(): string
    {
        $temporaryPath = Environment::getVarPath() . '/transient';
        do {
            $temporaryFolderName = sprintf(
                '%s/impexp_%s_files_%d',
                $temporaryPath,
                $this->mode,
                random_int(1, PHP_INT_MAX)
            );
        } while (is_dir($temporaryFolderName));
        GeneralUtility::mkdir_deep($temporaryFolderName);
        return $temporaryFolderName;
    }

    public function removeTemporaryFolderName(): void
    {
        if (!empty($this->temporaryFolderName)) {
            GeneralUtility::rmdir($this->temporaryFolderName, true);
            $this->temporaryFolderName = null;
        }
    }

    /**
     * Returns a \TYPO3\CMS\Core\Resource\Folder object for saving export files
     * to the server and is also used for uploading import files.
     */
    public function getOrCreateDefaultImportExportFolder(): ?Folder
    {
        if (empty($this->defaultImportExportFolder)) {
            $this->createDefaultImportExportFolder();
        }
        return $this->defaultImportExportFolder;
    }

    /**
     * Creates a \TYPO3\CMS\Core\Resource\Folder object for saving export files
     * to the server and is also used for uploading import files.
     */
    protected function createDefaultImportExportFolder(): void
    {
        $defaultTemporaryFolder = $this->getDefaultUploadTemporaryFolder();
        $defaultImportExportFolder = null;
        if ($defaultTemporaryFolder !== null) {
            $importExportFolderName = 'importexport';
            if ($defaultTemporaryFolder->hasFolder($importExportFolderName) === false) {
                $defaultImportExportFolder = $defaultTemporaryFolder->createFolder($importExportFolderName);
            } else {
                $defaultImportExportFolder = $defaultTemporaryFolder->getSubfolder($importExportFolderName);
            }
        }
        $this->defaultImportExportFolder = $defaultImportExportFolder;
    }

    /**
     * Returns a \TYPO3\CMS\Core\Resource\Folder object that could be used for uploading
     * temporary files in user context. The folder _temp_ below the default upload folder
     * of the user is used.
     */
    protected function getDefaultUploadTemporaryFolder(): ?Folder
    {
        $defaultFolder = $this->defaultUploadFolderResolver->resolve($this->getBackendUser());
        if ($defaultFolder !== false) {
            $tempFolderName = '_temp_';
            $createFolder = !$defaultFolder->hasFolder($tempFolderName);
            if ($createFolder === true) {
                try {
                    return $defaultFolder->createFolder($tempFolderName);
                } catch (Exception $folderAccessException) {
                }
            } else {
                return $defaultFolder->getSubfolder($tempFolderName);
            }
        }
        return null;
    }

    public function removeDefaultImportExportFolder(): void
    {
        if (!empty($this->defaultImportExportFolder)) {
            $this->defaultImportExportFolder->delete();
            $this->defaultImportExportFolder = null;
        }
    }

    /**
     * Checks if the input path relative to the public web path can be found in the file mounts of the backend user.
     * If not, it checks all file mounts of the user for the relative path and returns it if found.
     *
     * @param string $dirPrefix Path relative to public web path.
     * @param bool $checkAlternatives If set to false, do not look for an alternative path.
     * @return string|null If a path is available, it will be returned, otherwise NULL.
     * @throws \Exception
     */
    protected function resolveStoragePath(string $dirPrefix, bool $checkAlternatives = true): ?string
    {
        try {
            $this->resourceFactory->getFolderObjectFromCombinedIdentifier($dirPrefix);
            return $dirPrefix;
        } catch (InsufficientFolderAccessPermissionsException) {
            if ($checkAlternatives) {
                $storagesByUser = $this->getBackendUser()->getFileStorages();
                foreach ($storagesByUser as $storage) {
                    try {
                        $folder = $storage->getFolder(rtrim($dirPrefix, '/'));
                        return $folder->getPublicUrl();
                    } catch (InsufficientFolderAccessPermissionsException) {
                    }
                }
            }
        }
        return null;
    }

    /**
     * Recursively flattening the $pageTree array to a one-dimensional array with uid-pid pairs.
     *
     * @param array $pageTree Page tree array
     * @param array $list List with uid-pid pairs
     * @param int $pid PID value (internal, don't set from outside)
     */
    protected function flatInversePageTree(array $pageTree, array &$list, int $pid = -1): void
    {
        // @todo: return $list instead of by-reference?!
        $pageTreeInverse = array_reverse($pageTree);
        foreach ($pageTreeInverse as $page) {
            $list[$page['uid']] = $pid;
            if (is_array($page['subrow'] ?? null)) {
                $this->flatInversePageTree($page['subrow'], $list, (int)$page['uid']);
            }
        }
    }

    /**
     * Returns TRUE if the input table name is to be regarded as a static relation (that is, not exported etc).
     *
     * @param string $table Table name
     * @return bool TRUE, if table is marked static
     */
    protected function isTableStatic(string $table): bool
    {
        if (!$this->tcaSchemaFactory->has($table)) {
            return false;
        }
        return in_array($table, $this->relStaticTables, true)
            || in_array('_ALL', $this->relStaticTables, true);
    }

    /**
     * Returns TRUE if the element should be excluded from import and export.
     *
     * @param string $table Table name
     * @param int $uid Record UID
     * @return bool TRUE, if the record should be excluded
     */
    protected function isRecordExcluded(string $table, int $uid): bool
    {
        return (bool)($this->excludeMap[$table . ':' . $uid] ?? false);
    }

    /**
     * Returns TRUE if the soft reference should be included in export.
     *
     * @param string $tokenID Token ID for soft reference
     * @return bool TRUE, if soft reference should be included
     */
    protected function isSoftRefIncluded(string $tokenID): bool
    {
        $mode = $this->softrefCfg[$tokenID]['mode'] ?? '';
        return $tokenID && $mode !== Import::SOFTREF_IMPORT_MODE_EXCLUDE && $mode !== Import::SOFTREF_IMPORT_MODE_EDITABLE;
    }

    /**
     * Returns given fields of record if it exists.
     *
     * @param string $table Table name
     * @param int $uid UID of record
     * @param string $fields Field list to select. Default is "uid,pid"
     * @return array|null Result of \TYPO3\CMS\Backend\Utility\BackendUtility::getRecord() which means the record if found, otherwise NULL
     */
    protected function getRecordFromDatabase(string $table, int $uid, string $fields = 'uid,pid'): ?array
    {
        return BackendUtility::getRecord($table, $uid, $fields);
    }

    /**
     * Returns the page title path of a PID value. Results are cached internally
     *
     * @param int $pid Record PID to check
     * @return string The path for the input PID
     */
    protected function getRecordPath(int $pid): string
    {
        if (!isset($this->cacheGetRecordPath[$pid])) {
            $this->cacheGetRecordPath[$pid] = (string)BackendUtility::getRecordPath(
                $pid,
                $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW),
                20
            );
        }
        return $this->cacheGetRecordPath[$pid];
    }

    /**
     * Compares two records, the current database record and the one from the import memory.
     * Will return HTML code to show any differences between them!
     *
     * @param array $databaseRecord Database record, all fields (old values)
     * @param array $importRecord Import memory record for the same table/uid, all fields (new values)
     * @param string $table The table name of the record
     * @param bool $inverse Inverse the diff view (switch red/green, needed for pre-update difference view)
     * @return string HTML
     */
    protected function compareRecords(array $databaseRecord, array $importRecord, string $table, bool $inverse = false): string
    {
        $diffHtml = '';

        $languageService = $this->getLanguageService();
        $schema = $this->tcaSchemaFactory->get($table);
        // Updated fields
        foreach ($databaseRecord as $fieldName => $_) {
            if (!$schema->hasField($fieldName)) {
                continue;
            }
            $fieldInfo = $schema->getField($fieldName);
            if (!$fieldInfo->isType(TableColumnType::PASSTHROUGH)) {
                if (isset($importRecord[$fieldName])) {
                    if (trim((string)$databaseRecord[$fieldName]) !== trim((string)$importRecord[$fieldName])) {
                        $diffFieldHtml = $this->diffUtility->diff(
                            strip_tags((string)BackendUtility::getProcessedValue(
                                $table,
                                $fieldName,
                                !$inverse ? $importRecord[$fieldName] : $databaseRecord[$fieldName],
                                0,
                                true,
                                true
                            )),
                            strip_tags((string)BackendUtility::getProcessedValue(
                                $table,
                                $fieldName,
                                !$inverse ? $databaseRecord[$fieldName] : $importRecord[$fieldName],
                                0,
                                true,
                                true
                            ))
                        );
                        $diffHtml .= sprintf(
                            '<tr><td>%s (%s)</td><td>%s</td></tr>' . PHP_EOL,
                            htmlspecialchars($languageService->sL($fieldInfo->getLabel())),
                            htmlspecialchars((string)$fieldName),
                            $diffFieldHtml
                        );
                    }
                    unset($importRecord[$fieldName]);
                }
            }
        }

        // New fields
        foreach ($importRecord as $fieldName => $_) {
            if (!$schema->hasField($fieldName)) {
                continue;
            }
            $fieldInfo = $schema->getField($fieldName);
            if (!$fieldInfo->isType(TableColumnType::PASSTHROUGH)) {
                $diffFieldHtml = '<strong>Field missing</strong> in database';
                $diffHtml .= sprintf(
                    '<tr><td>%s (%s)</td><td>%s</td></tr>' . PHP_EOL,
                    htmlspecialchars($languageService->sL($fieldInfo->getLabel())),
                    htmlspecialchars((string)$fieldName),
                    $diffFieldHtml
                );
            }
        }

        if ($diffHtml !== '') {
            $diffHtml = '<table class="table table-striped table-hover">' . PHP_EOL . $diffHtml . '</table>';
        } else {
            $diffHtml = 'Match';
        }

        return sprintf(
            '<strong class="text-nowrap">[%s]:</strong>' . PHP_EOL . '%s',
            htmlspecialchars($table . ':' . $importRecord['uid'] . ' => ' . $databaseRecord['uid']),
            $diffHtml
        );
    }

    /**
     * Returns file processing object, initialized only once.
     *
     * @return ExtendedFileUtility File processor object
     */
    protected function getFileProcObj(): ExtendedFileUtility
    {
        if ($this->fileProcObj === null) {
            $this->fileProcObj = GeneralUtility::makeInstance(ExtendedFileUtility::class);
            $this->fileProcObj->setActionPermissions();
        }
        return $this->fileProcObj;
    }

    /**
     * Sets error message in the internal error log
     *
     * @param string $message Error message
     */
    protected function addError(string $message): void
    {
        $this->errorLog[] = $message;
    }

    public function hasErrors(): bool
    {
        return empty($this->errorLog) === false;
    }

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

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

    public function getPid(): int
    {
        return $this->pid;
    }

    public function setPid(int $pid): void
    {
        $this->pid = $pid;
        $this->pidRecord = null;
    }

    /**
     * Return record of root page of import or of export page tree
     * - or null if access denied to that page.
     *
     * If the page is the root of the page tree,
     * add some basic but missing information.
     */
    protected function getPidRecord(): ?array
    {
        if ($this->pidRecord === null && $this->pid >= 0) {
            $pidRecord = BackendUtility::readPageAccess(
                $this->pid,
                $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW)
            );
            if (is_array($pidRecord)) {
                if ($this->pid === 0) {
                    $pidRecord += ['title' => '[root-level]', 'uid' => 0, 'pid' => 0];
                }
                $this->pidRecord = $pidRecord;
            }
        }

        return $this->pidRecord;
    }

    /**
     * Set flag to control whether disabled records and their children are excluded (true) or included (false). Defaults
     * to the old behaviour of including everything.
     *
     * @param bool $excludeDisabledRecords Set to true if if all disabled records should be excluded, false otherwise
     */
    public function setExcludeDisabledRecords(bool $excludeDisabledRecords): void
    {
        $this->excludeDisabledRecords = $excludeDisabledRecords;
    }

    public function setExcludeMap(array $excludeMap): void
    {
        $this->excludeMap = $excludeMap;
    }

    public function setSoftrefCfg(array $softrefCfg): void
    {
        $this->softrefCfg = $softrefCfg;
    }

    public function setExtensionDependencies(array $extensionDependencies): void
    {
        $this->extensionDependencies = $extensionDependencies;
    }

    public function setShowStaticRelations(bool $showStaticRelations): void
    {
        $this->showStaticRelations = $showStaticRelations;
    }

    public function setRelStaticTables(array $relStaticTables): void
    {
        $this->relStaticTables = $relStaticTables;
    }

    public function getErrorLog(): array
    {
        return $this->errorLog;
    }

    public function setUpdate(bool $update): void
    {
        $this->update = $update;
    }

    public function setImportMode(array $importMode): void
    {
        $this->importMode = $importMode;
    }

    public function setGlobalIgnorePid(bool $globalIgnorePid): void
    {
        $this->globalIgnorePid = $globalIgnorePid;
    }

    public function setForceAllUids(bool $forceAllUids): void
    {
        $this->forceAllUids = $forceAllUids;
    }

    public function setShowDiff(bool $showDiff): void
    {
        $this->showDiff = $showDiff;
    }

    public function setSoftrefInputValues(array $softrefInputValues): void
    {
        $this->softrefInputValues = $softrefInputValues;
    }

    public function getMode(): string
    {
        return $this->mode;
    }

    public function getImportMapId(): array
    {
        return $this->importMapId;
    }
}
