<?php
/************************************************************************
 * This file is part of EspoCRM.
 *
 * EspoCRM – Open Source CRM application.
 * Copyright (C) 2014-2026 EspoCRM, Inc.
 * Website: https://www.espocrm.com
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 *
 * The interactive user interfaces in modified source and object code versions
 * of this program must display Appropriate Legal Notices, as required under
 * Section 5 of the GNU Affero General Public License version 3.
 *
 * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
 * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
 ************************************************************************/

namespace Espo\Core\Record;

use Espo\Core\Binding\BindingContainer;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\ConflictSilent;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Exceptions\NotFoundSilent;
use Espo\Core\FieldSanitize\SanitizeManager;
use Espo\Core\ORM\Defs\AttributeParam;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\ORM\Repository\Option\RemoveOption;
use Espo\Core\Record\Deleted\RestorerFactory;
use Espo\ORM\Repository\Option\SaveContext;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Record\Access\LinkCheck;
use Espo\Core\Record\ActionHistory\Action;
use Espo\Core\Record\ActionHistory\ActionLogger;
use Espo\Core\Record\ConcurrencyControl\OptimisticProcessor;
use Espo\Core\Record\Defaults\Populator as DefaultsPopulator;
use Espo\Core\Record\Defaults\PopulatorFactory as DefaultsPopulatorFactory;
use Espo\Core\Record\DynamicLogic\InputFilterProcessor;
use Espo\Core\Record\Formula\Processor as FormulaProcessor;
use Espo\Core\Record\Input\Data;
use Espo\Core\Record\Input\Filter;
use Espo\Core\Record\Input\FilterProvider;
use Espo\Core\Select\Primary\Filters\One;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\Core\Utils\Json;
use Espo\Core\Acl;
use Espo\Core\Acl\Table as AclTable;
use Espo\Core\Duplicate\Finder as DuplicateFinder;
use Espo\Core\FieldProcessing\ListLoadProcessor;
use Espo\Core\FieldProcessing\Loader\Params as FieldLoaderParams;
use Espo\Core\FieldProcessing\ReadLoadProcessor;
use Espo\Core\FieldValidation\FieldValidationParams as FieldValidationParams;
use Espo\Core\Record\Collection as RecordCollection;
use Espo\Core\Record\Duplicator\EntityDuplicator;
use Espo\Core\Record\Select\ApplierClassNameListProvider;
use Espo\Core\Select\SearchParams;
use Espo\Core\Di;
use Espo\ORM\Defs\Params\FieldParam;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\ORM\Entity;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Repository\RDBRepository;
use Espo\ORM\Collection;
use Espo\ORM\Query\Part\WhereClause;
use Espo\ORM\Type\AttributeType;
use Espo\Tools\Stream\Service as StreamService;
use Espo\Entities\User;

use stdClass;
use InvalidArgumentException;
use LogicException;
use RuntimeException;

/**
 * The layer between a controller and ORM repository. For CRUD and other operations with records.
 * Access control is processed here.
 *
 * Extending is not recommended. Use composition with metadata > recordDefs.
 *
 * @template TEntity of Entity
 * @implements Crud<TEntity>
 */
class Service implements Crud,

    Di\ConfigAware,
    Di\ServiceFactoryAware,
    Di\EntityManagerAware,
    Di\UserAware,
    Di\MetadataAware,
    Di\AclAware,
    Di\InjectableFactoryAware,
    Di\FieldUtilAware,
    Di\FieldValidationManagerAware,
    Di\RecordServiceContainerAware,
    Di\AssignmentCheckerManagerAware
{
    use Di\ConfigSetter;
    use Di\ServiceFactorySetter;
    use Di\EntityManagerSetter;
    use Di\UserSetter;
    use Di\MetadataSetter;
    use Di\AclSetter;
    use Di\InjectableFactorySetter;
    use Di\FieldUtilSetter;
    use Di\FieldValidationManagerSetter;
    use Di\RecordServiceContainerSetter;
    use Di\AssignmentCheckerManagerSetter;

    protected string $entityType;
    protected bool $getEntityBeforeUpdate = false;

    protected bool $maxSelectTextAttributeLengthDisabled = false;
    protected ?int $maxSelectTextAttributeLength = null;

    private ?StreamService $streamService = null;

    private ?ListLoadProcessor $listLoadProcessor = null;
    private ?DuplicateFinder $duplicateFinder = null;
    private ?LinkCheck $linkCheck = null;
    private ?ActionLogger $actionLogger = null;
    /** @var ?DefaultsPopulator<Entity> */
    private ?DefaultsPopulator $defaultsPopulator = null;
    private ?HookManager $recordHookManager = null;
    /** @var ?Filter[] */
    private ?array $createFilterList = null;
    /** @var ?Filter[] */
    private ?array $updateFilterList = null;
    /** @var ?Output\Filter<Entity>[] */
    private ?array $outputFilterList = null;

    protected const int MAX_SELECT_TEXT_ATTRIBUTE_LENGTH = 10000;

    public function __construct(
        protected SelectBuilderFactory $selectBuilderFactory,
        string $entityType = '',
    ) {
        $this->entityType = $entityType;

        $this->initEntityType();
    }

    /**
     * @internal
     */
    protected function initEntityType(): void
    {}

    /**
     * @return RDBRepository<TEntity>
     */
    protected function getRepository(): RDBRepository
    {
        return $this->entityManager->getRDBRepository($this->entityType);
    }

    /**
     * Add an action-history record.
     *
     * @param Action::* $action
     * @noinspection PhpDocSignatureInspection
     */
    public function processActionHistoryRecord(string $action, Entity $entity): void
    {
        if (
            $this->config->get('actionHistoryDisabled') ||
            $this->metadata->get("recordDefs.$this->entityType.actionHistoryDisabled")
        ) {
            return;
        }

        $this->getActionLogger()->log($action, $entity);
    }

    private function getActionLogger(): ActionLogger
    {
        if (!$this->actionLogger) {
            $this->actionLogger = $this->injectableFactory->createResolved(ActionLogger::class);
        }

        return $this->actionLogger;
    }

    /**
     * Read a record by ID. Access control check is performed.
     *
     * Is not supposed to be directly used in customizations.
     *
     * @param non-empty-string $id
     * @return ReadResult<TEntity>
     * @throws NotFoundSilent If not found.
     * @throws Forbidden If no read access.
     */
    public function read(string $id, ReadParams $params = new ReadParams()): ReadResult
    {
        if ($id === '') {
            throw new InvalidArgumentException();
        }

        if (!$this->acl->check($this->entityType, AclTable::ACTION_READ)) {
            throw new ForbiddenSilent("No read access.");
        }

        $entity = $this->getEntity($id);

        if (!$entity) {
            throw new NotFoundSilent("Record $id does not exist.");
        }

        $this->getRecordHookManager()->processBeforeRead($entity, $params);
        $this->processActionHistoryRecord(Action::READ, $entity);

        return new ReadResult($entity);
    }

    /**
     * Get an entity by ID. Access control check is performed.
     *
     * @throws Forbidden If no read access.
     * @return ?TEntity
     */
    public function getEntity(string $id): ?Entity
    {
        try {
            $builder = $this->selectBuilderFactory->create()
                ->from($this->entityType)
                ->withSearchParams(
                    SearchParams::create()
                        ->withSelect(['*'])
                        ->withPrimaryFilter(One::NAME)
                )
                ->withAdditionalApplierClassNameList(
                    $this->createSelectApplierClassNameListProvider()->get($this->entityType)
                );

            // @todo Apply access control filter. If a parameter enabled? Check compatibility.

            $query = $builder
                ->buildQueryBuilder()
                ->order([])
                ->build();
        } catch (BadRequest $e) {
            throw new RuntimeException($e->getMessage());
        }

        $entity = $this->getRepository()
            ->clone($query)
            ->where([Attribute::ID => $id])
            ->findOne();

        if (!$entity && $this->user->isAdmin()) {
            $entity = $this->getEntityEvenDeleted($id);
        }

        if (!$entity) {
            return null;
        }

        $this->loadAdditionalFields($entity);

        if (!$this->acl->check($entity, AclTable::ACTION_READ)) {
            throw new ForbiddenSilent("No 'read' access.");
        }

        $this->prepareEntityForOutput($entity);

        return $entity;
    }

    protected function getStreamService(): StreamService
    {
        if (empty($this->streamService)) {
            $this->streamService = $this->injectableFactory->create(StreamService::class);
        }

        return $this->streamService;
    }

    private function createReadLoadProcessor(): ReadLoadProcessor
    {
        return $this->injectableFactory->createWithBinding(ReadLoadProcessor::class, $this->createBinding());
    }

    private function getListLoadProcessor(): ListLoadProcessor
    {
        if (!$this->listLoadProcessor) {
            $this->listLoadProcessor =
                $this->injectableFactory->createWithBinding(ListLoadProcessor::class, $this->createBinding());
        }

        return $this->listLoadProcessor;
    }

    /**
     * @param TEntity $entity
     */
    public function loadAdditionalFields(Entity $entity): void
    {
        $loadProcessor = $this->createReadLoadProcessor();

        $loadProcessor->process($entity);
    }

    /**
     * @param Entity $entity
     */
    private function loadListAdditionalFields(Entity $entity, ?SearchParams $searchParams = null): void
    {
        $params = new FieldLoaderParams();

        if ($searchParams && $searchParams->getSelect()) {
            $params = $params->withSelect($searchParams->getSelect());
        }

        $loadProcessor = $this->getListLoadProcessor();

        $loadProcessor->process($entity, $params);
    }

    /**
     * Validate an entity.
     *
     * @param TEntity $entity An entity.
     * @param stdClass $data Raw input data.
     * @throws BadRequest
     */
    public function processValidation(Entity $entity, stdClass $data): void
    {
        $params = FieldValidationParams::create();

        $this->fieldValidationManager->process($entity, $data, $params);
    }

    /**
     * @param TEntity $entity
     * @throws Forbidden
     */
    protected function processAssignmentCheck(Entity $entity): void
    {
        if (!$this->checkAssignment($entity)) {
            throw new Forbidden("Assignment failure: assigned user or team not allowed.");
        }
    }

    /**
     * Check whether assignment can be applied for an entity.
     *
     * @param TEntity $entity
     */
    public function checkAssignment(Entity $entity): bool
    {
        return $this->assignmentCheckerManager->check($this->user, $entity);
    }

    private function getLinkCheck(): LinkCheck
    {
        if (!$this->linkCheck) {
            $linkCheck = $this->injectableFactory->createWithBinding(
                LinkCheck::class,
                BindingContainerBuilder::create()
                    ->bindInstance(Acl::class, $this->acl)
                    ->bindInstance(User::class, $this->user)
                    ->build()
            );

            $this->linkCheck = $linkCheck;
        }

        return $this->linkCheck;
    }

    /**
     * Sanitize input data.
     *
     * @param stdClass $data Input data.
     * @since 8.1.0
     */
    public function sanitizeInput(stdClass $data): void
    {
        $manager = $this->injectableFactory->create(SanitizeManager::class);

        $manager->process($this->entityType, $data);

        $this->sanitizeInputForeign($data);
    }

    private function sanitizeInputForeign(stdClass $data): void
    {
        $entityDefs = $this->entityManager->getDefs()->getEntity($this->entityType);

        /** @var array<string, Entity> $map */
        $map = [];

        foreach ($entityDefs->getFieldList() as $fieldDefs) {
            if ($fieldDefs->getType() !== FieldType::FOREIGN) {
                continue;
            }

            $link = $fieldDefs->getParam(FieldParam::LINK);
            $foreignField = $fieldDefs->getParam(FieldParam::FIELD);

            if (!$link || !$foreignField) {
                continue;
            }

            $foreignEntityType = $entityDefs->tryGetRelation($link)?->tryGetForeignEntityType();

            if (!$foreignEntityType) {
                continue;
            }

            $id = $data->{$link . 'Id'} ?? null;

            if (!is_string($id)) {
                continue;
            }

            if (!array_key_exists($link, $map)) {
                $map[$link] = $this->entityManager->getEntityById($foreignEntityType, $id);
            }

            $foreignEntity = $map[$link] ?? null;

            if (!$foreignEntity) {
                continue;
            }

            $field = $fieldDefs->getName();

            $data->$field = $foreignEntity->get($foreignField);
        }
    }

    protected function filterInput(stdClass $data): void
    {
        $forbiddenAttributeList = $this->acl
            ->getScopeForbiddenAttributeList($this->entityType, AclTable::ACTION_EDIT);

        foreach ($forbiddenAttributeList as $attribute) {
            unset($data->$attribute);
        }

        $this->filterInputForeignAttributes($data);
    }

    private function filterInputForeignAttributes(stdClass $data): void
    {
        $entityDefs = $this->entityManager->getDefs()->tryGetEntity($this->entityType);

        if (!$entityDefs) {
            return;
        }

        foreach ($entityDefs->getAttributeList() as $attributeDefs) {
            if (
                $attributeDefs->getType() !== AttributeType::FOREIGN &&
                !$attributeDefs->getParam(AttributeParam::IS_LINK_MULTIPLE_NAME_MAP)
            ) {
                continue;
            }

            if (
                // link-one
                $attributeDefs->getType() === AttributeType::FOREIGN &&
                $attributeDefs->getParam('attributeRole') === 'id'
            ) {
                continue;
            }

            $attribute = $attributeDefs->getName();

            unset($data->$attribute);
        }
    }

    private function filterInputSystemAttributes(stdClass $data): void
    {
        unset($data->deleted);
        unset($data->id);
        unset($data->modifiedById);
        unset($data->modifiedByName);
        unset($data->modifiedAt);
        unset($data->createdById);
        unset($data->createdByName);
        unset($data->createdAt);
        unset($data->versionNumber);
    }

    public function filterCreateInput(stdClass $data): void
    {
        $this->filterInputSystemAttributes($data);
        $this->filterInput($data);

        $wrappedData = new Data($data);

        foreach ($this->getCreateFilterList() as $filter) {
            $filter->filter($wrappedData);
        }
    }

    public function filterUpdateInput(stdClass $data): void
    {
        $this->filterInputSystemAttributes($data);
        $this->filterInput($data);
        $this->filterReadOnlyAfterCreate($data);

        $wrappedData = new Data($data);

        foreach ($this->getUpdateFilterList() as $filter) {
            $filter->filter($wrappedData);
        }
    }

    private function createFilterProvider(): FilterProvider
    {
        return $this->injectableFactory->createWithBinding(FilterProvider::class, $this->createBinding());
    }

    /**
     * @return Filter[]
     */
    private function getCreateFilterList(): array
    {
        if ($this->createFilterList === null) {
            $this->createFilterList = $this->createFilterProvider()->getForCreate($this->entityType);
        }

        return $this->createFilterList;
    }

    /**
     * @return Filter[]
     */
    private function getUpdateFilterList(): array
    {
        if ($this->updateFilterList === null) {
            $this->updateFilterList = $this->createFilterProvider()->getForUpdate($this->entityType);
        }

        return $this->updateFilterList;
    }

    private function filterReadOnlyAfterCreate(stdClass $data): void
    {
        $fieldDefsList = $this->entityManager
            ->getDefs()
            ->getEntity($this->entityType)
            ->getFieldList();

        foreach ($fieldDefsList as $fieldDefs) {
            if (!$fieldDefs->getParam(FieldParam::READ_ONLY_AFTER_CREATE)) {
                continue;
            }

            $attributeList = $this->fieldUtil->getAttributeList($this->entityType, $fieldDefs->getName());

            foreach ($attributeList as $attribute) {
                unset($data->$attribute);
            }
        }
    }

    /**
     * @param TEntity $entity
     * @throws Conflict
     */
    private function processConcurrencyControl(Entity $entity, int $versionNumber): void
    {
        // @todo Use a bound interface.
        $processor = $this->injectableFactory->create(OptimisticProcessor::class);

        $result = $processor->process($entity, $versionNumber);

        if (!$result) {
            return;
        }

        $responseData = (object) [
            'values' => $result->values,
            'versionNumber' => $result->versionNumber,
        ];

        throw ConflictSilent::createWithBody('modified', Json::encode($responseData));
    }

    /**
     * @param TEntity $entity
     * @throws Conflict
     */
    protected function processDuplicateCheck(Entity $entity): void
    {
        $duplicates = $this->findDuplicates($entity);

        if (!$duplicates) {
            return;
        }

        foreach ($duplicates as $e) {
            $this->prepareEntityForOutput($e);
        }

        throw ConflictSilent::createWithBody('duplicate', Json::encode($duplicates->getValueMapList()));
    }

    /**
     * @param TEntity $entity
     * @noinspection PhpUnusedParameterInspection
     */
    public function populateDefaults(Entity $entity, stdClass $data): void
    {
        $this->getDefaultsPopulator()->populate($entity);
    }

    /**
     * @return DefaultsPopulator<Entity>
     */
    private function getDefaultsPopulator(): DefaultsPopulator
    {
        if (!$this->defaultsPopulator) {
            $this->defaultsPopulator = $this->injectableFactory
                ->create(DefaultsPopulatorFactory::class)
                ->create($this->entityType);
        }

        return $this->defaultsPopulator;
    }

    /**
     * Create a record.
     *
     * Is not supposed to be directly used in customizations.
     *
     * @return CreateResult<TEntity>
     * @throws BadRequest
     * @throws Forbidden If no create access.
     * @throws Conflict
     */
    public function create(stdClass $data, CreateParams $params = new CreateParams()): CreateResult
    {
        if (!$this->acl->check($this->entityType, AclTable::ACTION_CREATE)) {
            throw new ForbiddenSilent("No create access.");
        }

        $entity = $this->getRepository()->getNew();

        $this->filterCreateInput($data);
        $this->sanitizeInput($data);

        $entity->setMultiple($data);
        $this->populateDefaults($entity, $data);

        $this->getRecordHookManager()->processEarlyBeforeCreate($entity, $params);

        $this->processValidation($entity, $data);
        $this->checkEntityCreateAccess($entity);
        $this->processAssignmentCheck($entity);
        $this->getLinkCheck()->processFields($entity);

        if (!$params->skipDuplicateCheck()) {
            $this->processDuplicateCheck($entity);
        }

        $this->processApiBeforeCreateApiScript($entity, $params);
        $this->getRecordHookManager()->processBeforeCreate($entity, $params);
        $this->beforeCreateEntity($entity, $data);

        $this->entityManager->saveEntity($entity, [
            SaveOption::API => true,
            SaveOption::KEEP_NEW => true,
            SaveOption::DUPLICATE_SOURCE_ID => $params->getDuplicateSourceId(),
        ]);

        $this->getRecordHookManager()->processAfterCreate($entity, $params);

        $entity->setAsNotNew();
        $entity->updateFetchedValues();

        $this->afterCreateEntity($entity, $data);
        $this->afterCreateProcessDuplicating($entity, $params);

        $this->loadAdditionalFields($entity);

        $this->prepareEntityForOutput($entity);
        $this->processActionHistoryRecord(Action::CREATE, $entity);

        return new CreateResult($entity);
    }

    /**
     * Update a record.
     *
     * Is not supposed to be directly used in customizations.
     *
     * @return UpdateResult<TEntity>
     * @throws NotFound If record not found.
     * @throws Forbidden If no access.
     * @throws Conflict
     * @throws BadRequest
     */
    public function update(string $id, stdClass $data, UpdateParams $params = new UpdateParams()): UpdateResult
    {
        if (!$this->acl->check($this->entityType, AclTable::ACTION_EDIT)) {
            throw new ForbiddenSilent("No edit access.");
        }

        if (!$id) {
            throw new BadRequest("ID is empty.");
        }

        $this->filterUpdateInput($data);
        $this->sanitizeInput($data);

        $entity = $this->getEntityBeforeUpdate ?
            $this->getEntity($id) :
            $this->getRepository()->getById($id);

        if (!$entity) {
            throw new NotFound("Record $id not found.");
        }

        if (!$this->getEntityBeforeUpdate) {
            $this->loadAdditionalFields($entity);
        }

        $this->filterInputReadOnlySaved($entity, $data);

        if (!$this->acl->check($entity, AclTable::ACTION_EDIT)) {
            throw new ForbiddenSilent("No edit access.");
        }

        $entity->setMultiple($data);

        if ($params->getVersionNumber() !== null) {
            $this->processConcurrencyControl($entity, $params->getVersionNumber());
        }

        $this->getRecordHookManager()->processEarlyBeforeUpdate($entity, $params);

        $this->processValidation($entity, $data);
        $this->processAssignmentCheck($entity);
        $this->getLinkCheck()->processFields($entity);

        $checkForDuplicates =
            $this->metadata->get(['recordDefs', $this->entityType, 'updateDuplicateCheck']) ?? false;

        if ($checkForDuplicates && !$params->skipDuplicateCheck()) {
            $this->processDuplicateCheck($entity);
        }

        $this->processApiBeforeUpdateApiScript($entity, $params);
        $this->getRecordHookManager()->processBeforeUpdate($entity, $params);
        $this->beforeUpdateEntity($entity, $data);

        $context = new SaveContext();

        $this->entityManager->saveEntity($entity, [
            SaveOption::API => true,
            SaveOption::KEEP_DIRTY => true,
            SaveContext::NAME => $context,
        ]);

        $this->getRecordHookManager()->processAfterUpdate($entity, $params);
        $entity->updateFetchedValues();
        $this->afterUpdateEntity($entity, $data);

        if ($this->metadata->get(['recordDefs', $this->entityType, 'loadAdditionalFieldsAfterUpdate'])) {
            $this->loadAdditionalFields($entity);
        }

        $this->prepareEntityForOutput($entity);
        $this->processActionHistoryRecord(Action::UPDATE, $entity);

        return new UpdateResult(
            entity: $entity,
            linkUpdated: $context->isLinkUpdated(),
        );
    }

    /**
     * Delete a record.
     *
     * Is not supposed to be directly used in customizations.
     *
     * @throws Forbidden
     * @throws BadRequest
     * @throws NotFound
     * @throws Conflict
     */
    public function delete(string $id, DeleteParams $params = new DeleteParams()): DeleteResult
    {
        if (!$this->acl->check($this->entityType, AclTable::ACTION_DELETE)) {
            throw new ForbiddenSilent("No delete access.");
        }

        if (!$id) {
            throw new BadRequest("ID is empty.");
        }

        $entity = $this->getRepository()->getById($id);

        if (!$entity) {
            throw new NotFound("Record $id not found.");
        }

        if (!$this->acl->check($entity, AclTable::ACTION_DELETE)) {
            throw new ForbiddenSilent("No delete access.");
        }

        $this->getRecordHookManager()->processBeforeDelete($entity, $params);
        $this->beforeDeleteEntity($entity);

        $this->getRepository()->remove($entity, [RemoveOption::API => true]);

        $this->afterDeleteEntity($entity);
        $this->getRecordHookManager()->processAfterDelete($entity, $params);
        $this->processActionHistoryRecord(Action::DELETE, $entity);

        return new DeleteResult();
    }

    /**
     * Find records.
     *
     * @return RecordCollection<TEntity>
     * @throws Forbidden
     * @throws BadRequest
     */
    public function find(SearchParams $searchParams, ?FindParams $params = null): RecordCollection
    {
        if (!$this->acl->check($this->entityType, AclTable::ACTION_READ)) {
            throw new ForbiddenSilent("No read access.");
        }

        if (!$params) {
            $params = FindParams::create();
        }

        $disableCount = $params->noTotal() ||
            $this->metadata->get(['entityDefs', $this->entityType, 'collection', 'countDisabled']);

        $maxSize = $searchParams->getMaxSize();

        if ($disableCount && $maxSize) {
            $searchParams = $searchParams->withMaxSize($maxSize + 1);
        }

        $preparedSearchParams = $this->prepareSearchParams($searchParams);

        try {
            $query = $this->selectBuilderFactory->create()
                ->from($this->entityType)
                ->withStrictAccessControl()
                ->withSearchParams($preparedSearchParams)
                ->withAdditionalApplierClassNameList(
                    $this->createSelectApplierClassNameListProvider()->get($this->entityType)
                )
                ->build();
        } catch (Forbidden $e) {
            throw new BadRequest($e->getMessage(), 400, $e);
        }

        $collection = $this->getRepository()
            ->clone($query)
            ->find();

        foreach ($collection as $entity) {
            $this->loadListAdditionalFields($entity, $preparedSearchParams);
            $this->prepareEntityForOutput($entity);
        }

        if ($disableCount) {
            return RecordCollection::createNoCount($collection, $maxSize);
        }

        $total = $this->getRepository()
            ->clone($query)
            ->count();

        return RecordCollection::create($collection, $total);
    }

    private function createSelectApplierClassNameListProvider(): ApplierClassNameListProvider
    {
        return $this->injectableFactory->create(ApplierClassNameListProvider::class);
    }

    /**
     * @return ?TEntity
     */
    private function getEntityEvenDeleted(string $id): ?Entity
    {
        $query = $this->entityManager
            ->getQueryBuilder()
            ->select()
            ->from($this->entityType)
            ->where([
                Attribute::ID => $id,
            ])
            ->withDeleted()
            ->build();

        return $this->getRepository()
            ->clone($query)
            ->findOne();
    }

    /**
     * Restore a deleted record.
     *
     * @throws NotFound If not found.
     * @throws Forbidden If no access.
     * @throws Conflict If a conflict occurred.
     */
    public function restoreDeleted(string $id): void
    {
        if (!$this->user->isAdmin()) {
            throw new Forbidden("Only admin can restore.");
        }

        $entity = $this->getEntityEvenDeleted($id);

        if (!$entity) {
            throw new NotFound();
        }

        if (!$entity->hasAttribute(Attribute::DELETED)) {
            throw new Forbidden("Record type is not restorable.");
        }

        if ($entity->get(Attribute::DELETED) !== true) {
            throw new Conflict("Record type is not soft-deleted.");
        }

        $factory = $this->injectableFactory->createWithBinding(RestorerFactory::class, $this->createBinding());
        $restorer = $factory->create($this->entityType);

        $restorer->restore($entity);
    }

    public function getMaxSelectTextAttributeLength(): ?int
    {
        if ($this->maxSelectTextAttributeLengthDisabled) {
            return null;
        }

        if ($this->maxSelectTextAttributeLength) {
            return $this->maxSelectTextAttributeLength;
        }

        return $this->config->get('maxSelectTextAttributeLengthForList') ??
            self::MAX_SELECT_TEXT_ATTRIBUTE_LENGTH;
    }

    /**
     * Find linked records.
     *
     * @param non-empty-string $link
     * @return RecordCollection<Entity>
     * @throws NotFound If a record not found.
     * @throws Forbidden If no access.
     * @throws BadRequest
     */
    public function findLinked(string $id, string $link, SearchParams $searchParams): RecordCollection
    {
        if ($link === '') {
            throw new InvalidArgumentException();
        }

        if (!$this->acl->check($this->entityType, AclTable::ACTION_READ)) {
            throw new ForbiddenSilent("No access.");
        }

        $entity = $this->getRepository()->getById($id);

        if (!$entity) {
            throw new NotFound();
        }

        if (!$this->acl->check($entity, AclTable::ACTION_READ)) {
            throw new ForbiddenSilent("No read access.");
        }

        $entityDefs = $this->entityManager
            ->getDefs()
            ->getEntity($this->entityType);

        if (!$entityDefs->hasRelation($link)) {
            throw new NotFound("Link does not exist.");
        }

        $this->processForbiddenLinkReadCheck($link);

        $foreignEntityType = $entityDefs
            ->getRelation($link)
            ->getForeignEntityType();

        $skipAcl = $this->metadata
            ->get("recordDefs.$this->entityType.relationships.$link.selectAccessControlDisabled") ?? false;

        if (!$skipAcl && !$this->acl->check($foreignEntityType, AclTable::ACTION_READ)) {
            throw new Forbidden();
        }

        $recordService = $this->recordServiceContainer->get($foreignEntityType);

        $disableCount = $this->metadata
            ->get("recordDefs.$this->entityType.relationships.$link.countDisabled") ?? false;

        $maxSize = $searchParams->getMaxSize();

        if ($disableCount && $maxSize) {
            $searchParams = $searchParams->withMaxSize($maxSize + 1);
        }

        $preparedSearchParams = $this->prepareLinkSearchParams(
            $recordService->prepareSearchParams($searchParams),
            $link
        );

        $selectBuilder = $this->selectBuilderFactory->create();

        $selectBuilder
            ->from($foreignEntityType)
            ->withSearchParams($preparedSearchParams)
            ->withAdditionalApplierClassNameList(
                $this->createSelectApplierClassNameListProvider()->get($foreignEntityType)
            );

        if (!$skipAcl) {
            $selectBuilder->withStrictAccessControl();
        } else {
            $selectBuilder->withComplexExpressionsForbidden();
            $selectBuilder->withWherePermissionCheck();
        }

        try {
            $query = $selectBuilder->build();
        } catch (Forbidden $e) {
            throw new BadRequest($e->getMessage(), 400, $e);
        }

        $collection = $this->entityManager
            ->getRDBRepository($this->entityType)
            ->getRelation($entity, $link)
            ->clone($query)
            ->find();

        foreach ($collection as $itemEntity) {
            $this->loadListAdditionalFields($itemEntity, $preparedSearchParams);

            $recordService->prepareEntityForOutput($itemEntity);
        }

        if ($disableCount) {
            return RecordCollection::createNoCount($collection, $maxSize);
        }

        $total = $this->entityManager
            ->getRDBRepository($this->entityType)
            ->getRelation($entity, $link)
            ->clone($query)
            ->count();

        return RecordCollection::create($collection, $total);
    }

    /**
     * Link records.
     *
     * @throws BadRequest
     * @throws Forbidden
     * @throws NotFound
     */
    public function link(string $id, string $link, string $foreignId): LinkResult
    {
        if (!$this->acl->check($this->entityType)) {
            throw new Forbidden();
        }

        if (empty($id) || empty($link) || empty($foreignId)) {
            throw new BadRequest();
        }

        $this->processForbiddenLinkEditCheck($link);

        $entity = $this->getRepository()->getById($id);

        if (!$entity) {
            throw new NotFound();
        }

        if (!$entity instanceof CoreEntity) {
            throw new LogicException("Only core entities are supported.");
        }

        $this->getLinkCheck()->processLink($entity, $link);

        $foreignEntityType = $entity->getRelationParam($link, RelationParam::ENTITY);

        if (!$foreignEntityType) {
            throw new NotFound("Entity $this->entityType does not have link $link.");
        }

        $foreignEntity = $this->entityManager->getEntityById($foreignEntityType, $foreignId);

        if (!$foreignEntity) {
            throw new NotFound();
        }

        $this->getLinkCheck()->processLinkForeign($entity, $link, $foreignEntity);

        $this->getRecordHookManager()->processBeforeLink($entity, $link, $foreignEntity);

        $this->getRepository()
            ->getRelation($entity, $link)
            ->relate($foreignEntity, null, [SaveOption::API => true]);

        $this->getRecordHookManager()->processAfterLink($entity, $link, $foreignEntity);

        return new LinkResult();
    }

    /**
     * Unlink records.
     *
     * @throws BadRequest
     * @throws Forbidden
     * @throws NotFound
      */
    public function unlink(string $id, string $link, string $foreignId): UnlinkResult
    {
        if (!$this->acl->check($this->entityType)) {
            throw new Forbidden();
        }

        if (empty($id) || empty($link) || empty($foreignId)) {
            throw new BadRequest();
        }

        $this->processForbiddenLinkEditCheck($link);

        $entity = $this->getRepository()->getById($id);

        if (!$entity) {
            throw new NotFound();
        }

        if (!$entity instanceof CoreEntity) {
            throw new LogicException("Only core entities are supported.");
        }

        $this->getLinkCheck()->processUnlink($entity, $link);

        $foreignEntityType = $entity->getRelationParam($link, RelationParam::ENTITY);

        if (!$foreignEntityType) {
            throw new NotFound("Entity $this->entityType does not have link $link.");
        }

        $foreignEntity = $this->entityManager->getEntityById($foreignEntityType, $foreignId);

        if (!$foreignEntity) {
            throw new NotFound();
        }

        $this->getLinkCheck()->processUnlinkForeign($entity, $link, $foreignEntity);

        $this->getRecordHookManager()->processBeforeUnlink($entity, $link, $foreignEntity);

        $this->getRepository()
            ->getRelation($entity, $link)
            ->unrelate($foreignEntity, [SaveOption::API => true]);

        $this->getRecordHookManager()->processAfterUnlink($entity, $link, $foreignEntity);

        return new UnlinkResult();
    }


    /**
     * @throws BadRequest
     * @throws Forbidden
     * @throws NotFound
     */
    public function massLink(string $id, string $link, SearchParams $searchParams): MassLinkResult
    {
        if (!$this->acl->check($this->entityType, AclTable::ACTION_EDIT)) {
            throw new Forbidden();
        }

        if (!$this->metadata->get("recordDefs.$this->entityType.relationships.$link.massLink")) {
            throw new Forbidden("Mass link is not allowed.");
        }

        $this->processForbiddenLinkEditCheck($link);

        $entity = $this->getRepository()->getById($id);

        if (!$entity) {
            throw new NotFound();
        }

        $this->getLinkCheck()->processLink($entity, $link);

        // Not used link-check deliberately. Only edit access.
        if (!$this->acl->check($entity, AclTable::ACTION_EDIT)) {
            throw new Forbidden();
        }

        if (!$entity instanceof CoreEntity) {
            throw new LogicException("Only core entities are supported.");
        }

        $foreignEntityType = $entity->getRelationParam($link, RelationParam::ENTITY);

        if (!$foreignEntityType) {
            throw new LogicException("Link '$link' has no 'entity'.");
        }

        $accessActionRequired = $this->metadata
            ->get("recordDefs.$this->entityType.relationships.$link.linkRequiredForeignAccess") ??
            AclTable::ACTION_EDIT;

        if (!$this->acl->check($foreignEntityType, $accessActionRequired)) {
            throw new Forbidden();
        }

        $query = $this->selectBuilderFactory->create()
            ->from($foreignEntityType)
            ->withStrictAccessControl()
            ->withSearchParams($searchParams->withSelect(null))
            ->build();

        if ($this->acl->getLevel($foreignEntityType, $accessActionRequired) === AclTable::LEVEL_ALL) {
            $this->getRepository()
                ->getRelation($entity, $link)
                ->massRelate($query, [SaveOption::API => true]);

            return new MassLinkResult(
                affectedCount: null,
            );
        }

        // @todo Apply access control filter if $accessActionRequired === 'read'. For better performance.

        $countRelated = 0;

        $foreignCollection = $this->entityManager
            ->getRDBRepository($foreignEntityType)
            ->clone($query)
            ->sth()
            ->find();

        foreach ($foreignCollection as $foreignEntity) {
            if (!$this->acl->check($foreignEntity, $accessActionRequired)) {
                continue;
            }

            $this->getRepository()
                ->getRelation($entity, $link)
                ->relate($foreignEntity, null, [SaveOption::API => true]);

            $countRelated++;
        }

        return new MassLinkResult(
            affectedCount: $countRelated,
        );
    }

    /**
     * @throws Forbidden
     */
    protected function processForbiddenLinkReadCheck(string $link): void
    {
        $forbiddenLinkList = $this->acl
            ->getScopeForbiddenLinkList($this->entityType);

        if (in_array($link, $forbiddenLinkList)) {
            throw new Forbidden();
        }
    }

    /**
     * @throws Forbidden
     */
    protected function processForbiddenLinkEditCheck(string $link): void
    {
        $type = $this->entityManager
            ->getDefs()
            ->getEntity($this->entityType)
            ->tryGetRelation($link)
            ?->getType();

        if (
            $type &&
            !in_array($type, [
                Entity::MANY_MANY,
                Entity::HAS_MANY,
                Entity::HAS_CHILDREN,
            ])
        ) {
            throw new Forbidden("Only manyMany, hasMany & hasChildren relations are allowed.");
        }

        $forbiddenLinkList = $this->acl->getScopeForbiddenLinkList($this->entityType, AclTable::ACTION_EDIT);

        if (in_array($link, $forbiddenLinkList)) {
            throw new Forbidden();
        }
    }

    /**
     * Follow a record.
     *
     * @param string $id A record ID.
     * @param string|null $userId A user ID. If not specified then a current user will be used.
     *
     * @throws NotFoundSilent
     * @throws Forbidden
     */
    public function follow(string $id, ?string $userId = null): void
    {
        if (!$this->acl->check($this->entityType, AclTable::ACTION_STREAM)) {
            throw new Forbidden();
        }

        $entity = $this->getRepository()->getById($id);

        if (!$entity) {
            throw new NotFoundSilent();
        }

        if (!$this->acl->check($entity, AclTable::ACTION_STREAM)) {
            throw new Forbidden();
        }

        if (empty($userId)) {
            $userId = $this->user->getId();
        }

        $this->getStreamService()->followEntity($entity, $userId);
    }

    /**
     * Unfollow a record.
     *
     * @param string $id A record ID.
     * @param string|null $userId A user ID. If not specified then a current user will be used.
     *
     * @throws NotFoundSilent
     */
    public function unfollow(string $id, ?string $userId = null): void
    {
        $entity = $this->getRepository()->getById($id);

        if (!$entity) {
            throw new NotFoundSilent();
        }

        if (empty($userId)) {
            $userId = $this->user->getId();
        }

        $this->getStreamService()->unfollowEntity($entity, $userId);
    }

    private function getDuplicateFinder(): DuplicateFinder
    {
        if (!$this->duplicateFinder) {
            $this->duplicateFinder = $this->injectableFactory->create(DuplicateFinder::class);
        }

        return $this->duplicateFinder;
    }

    /**
     * Check whether an entity has a duplicate.
     *
     * @param TEntity $entity
     */
    public function checkIsDuplicate(Entity $entity): bool
    {
        $finder = $this->getDuplicateFinder();

        // For backward compatibility.
        if (method_exists($this, 'getDuplicateWhereClause')) {
            $whereClause = $this->getDuplicateWhereClause($entity, (object) []);

            if (!$whereClause) {
                return false;
            }

            return $finder->checkByWhere($entity, WhereClause::fromRaw($whereClause));
        }

        return $finder->check($entity);
    }

    /**
     * Find duplicates for an entity.
     *
     * @return ?Collection<TEntity>
     */
    public function findDuplicates(Entity $entity): ?Collection
    {
        $finder = $this->getDuplicateFinder();

        // For backward compatibility.
        if (method_exists($this, 'getDuplicateWhereClause')) {
            $whereClause = $this->getDuplicateWhereClause($entity, (object) []);

            if (!$whereClause) {
                return null;
            }

            /** @var ?Collection<TEntity> */
            return $finder->findByWhere($entity, WhereClause::fromRaw($whereClause));
        }

        /** @var ?Collection<TEntity> */
        return $finder->find($entity);
    }

    /**
     * Prepare an entity for output. Clears not allowed attributes.
     *
     * Do not extend. Prefer metadata recordDefs > outputFilterClassNameList.
     *
     * @param TEntity $entity
     */
    public function prepareEntityForOutput(Entity $entity): void
    {
        $forbiddenAttributeList = $this->acl->getScopeForbiddenAttributeList($entity->getEntityType());

        foreach ($forbiddenAttributeList as $attribute) {
            $entity->clear($attribute);
        }

        foreach ($this->getOutputFilterList() as $filter) {
            $filter->filter($entity);
        }
    }

    /**
     * @return Output\Filter<Entity>[]
     */
    private function getOutputFilterList(): array
    {
        if ($this->outputFilterList === null) {
            $this->outputFilterList =
                $this->injectableFactory->create(Output\FilterProvider::class)->get($this->entityType);
        }

        return $this->outputFilterList;
    }

    private function createEntityDuplicator(): EntityDuplicator
    {
        return $this->injectableFactory->createWithBinding(EntityDuplicator::class, $this->createBinding());
    }

    /**
     * @throws BadRequest
     * @throws Forbidden
     * @throws ForbiddenSilent
     * @throws NotFound
     */
    public function getDuplicateAttributes(string $id): stdClass
    {
        if (!$id) {
            throw new BadRequest("No ID.");
        }

        if (!$this->acl->check($this->entityType, AclTable::ACTION_CREATE)) {
            throw new Forbidden("No 'create' access.");
        }

        if (!$this->acl->check($this->entityType, AclTable::ACTION_READ)) {
            throw new Forbidden("No 'read' access.");
        }

        $entity = $this->getEntity($id);

        if (!$entity) {
            throw new NotFound("Record not found.");
        }

        $attributes = $this->createEntityDuplicator()->duplicate($entity);

        if ($this->acl->getPermissionLevel(Acl\Permission::ASSIGNMENT) === AclTable::LEVEL_NO) {
            unset($attributes->assignedUserId);
            unset($attributes->assignedUserName);
            unset($attributes->assignedUsersIds);
        }

        return $attributes;
    }

    /**
     * @param TEntity $entity
     */
    private function afterCreateProcessDuplicating(Entity $entity, CreateParams $params): void
    {
        $duplicatingEntityId = $params->getDuplicateSourceId();

        if (!$duplicatingEntityId) {
            return;
        }

        /** @var ?TEntity $duplicatingEntity */
        $duplicatingEntity = $this->entityManager->getEntityById($entity->getEntityType(), $duplicatingEntityId);

        if (!$duplicatingEntity) {
            return;
        }

        if (!$this->acl->check($duplicatingEntity, AclTable::ACTION_READ)) {
            return;
        }

        $this->duplicateLinks($entity, $duplicatingEntity);
    }

    /**
     * @param TEntity $entity
     * @param TEntity $duplicatingEntity
     */
    private function duplicateLinks(Entity $entity, Entity $duplicatingEntity): void
    {
        $linkList = $this->metadata->get("recordDefs.$this->entityType.duplicateLinkList") ?? [];

        foreach ($linkList as $link) {
            $linkedList = $this->getRepository()
                ->getRelation($duplicatingEntity, $link)
                ->find();

            foreach ($linkedList as $linked) {
                $this->getRepository()
                    ->getRelation($entity, $link)
                    ->relate($linked);
            }
        }
    }

    public function prepareSearchParams(SearchParams $searchParams): SearchParams
    {
        $searchParams = $this->prepareSearchParamsSelect($searchParams);

        if ($searchParams->getSelect() === null) {
            $searchParams = $searchParams->withSelect(['*']);
        }

        return $searchParams
            ->withMaxTextAttributeLength(
                $this->getMaxSelectTextAttributeLength()
            );
    }

    protected function prepareSearchParamsSelect(SearchParams $searchParams): SearchParams
    {
        if ($this->metadata->get("recordDefs.$this->entityType.forceSelectAllAttributes")) {
            return $searchParams->withSelect(null);
        }

        if ($searchParams->getSelect() === null) {
            return $searchParams;
        }

        /** @var string[] $mandatoryAttributeList */
        $mandatoryAttributeList = $this->metadata->get("recordDefs.$this->entityType.mandatoryAttributeList") ?? [];

        if ($mandatoryAttributeList === []) {
            return $searchParams;
        }

        $select = array_unique(
            array_merge(
                $searchParams->getSelect(),
                $mandatoryAttributeList
            )
        );

        return $searchParams->withSelect($select);
    }

    /**
     * Do not extend.
     *
     * @internal
     */
    protected function prepareLinkSearchParams(SearchParams $searchParams, string $link): SearchParams
    {
        if ($searchParams->getSelect() === null) {
            return $searchParams;
        }

        $list = $this->metadata->get("recordDefs.$this->entityType.relationships.$link.mandatoryAttributeList") ?? [];

        if ($list === []) {
            return $searchParams;
        }

        $select = array_unique(array_merge($searchParams->getSelect(), $list));

        return $searchParams->withSelect($select);
    }

    /**
     * @param TEntity $entity
     * @throws Conflict
     * @throws BadRequest
     * @throws Forbidden
     */
    private function processApiBeforeCreateApiScript(Entity $entity, CreateParams $params): void
    {
        $processor = $this->injectableFactory->create(FormulaProcessor::class);

        $processor->processBeforeCreate($entity, $params);
    }

    /**
     * @param TEntity $entity
     * @throws Conflict
     * @throws BadRequest
     * @throws Forbidden
     */
    private function processApiBeforeUpdateApiScript(Entity $entity, UpdateParams $params): void
    {
        $processor = $this->injectableFactory->create(FormulaProcessor::class);

        $processor->processBeforeUpdate($entity, $params);
    }

    /**
     * Use a recordDefs hook instead.
     *
     * @param TEntity $entity
     * @internal
     */
    protected function beforeCreateEntity(Entity $entity, stdClass $data): void
    {}

    /**
     * Use a recordDefs hook instead.
     *
     * @param TEntity $entity
     * @internal
     */
    protected function afterCreateEntity(Entity $entity, stdClass $data): void
    {}

    /**
     * Use a recordDefs hook instead.
     *
     * @param TEntity $entity
     * @internal
     */
    protected function beforeUpdateEntity(Entity $entity, stdClass $data): void
    {}

    /**
     * Use a recordDefs hook instead.
     *
     * @param TEntity $entity
     * @internal
     */
    protected function afterUpdateEntity(Entity $entity, stdClass $data): void
    {}

    /**
     * Use a recordDefs hook instead.
     *
     * @param TEntity $entity
     * @internal
     */
    protected function beforeDeleteEntity(Entity $entity): void
    {}

    /**
     * Use a recordDefs hook instead.
     *
     * @param TEntity $entity
     * @internal
     */
    protected function afterDeleteEntity(Entity $entity): void
    {}

    private function createBinding(): BindingContainer
    {
        return BindingContainerBuilder::create()
            ->bindInstance(User::class, $this->user)
            ->bindInstance(Acl::class, $this->acl)
            ->build();
    }

    private function getRecordHookManager(): HookManager
    {
        if (!$this->recordHookManager) {
            $this->recordHookManager =
                $this->injectableFactory->createWithBinding(HookManager::class, $this->createBinding());
        }

        return $this->recordHookManager;
    }

    /**
     * @throws Forbidden
     */
    private function checkEntityCreateAccess(Entity $entity): void
    {
        if (!$this->acl->check($entity, AclTable::ACTION_CREATE)) {
            throw new ForbiddenSilent("No create access.");
        }
    }

    /**
     * Filter input by the read-only-pre-save dynamic logic.
     *
     * @since 9.1.0
     * @internal
     */
    public function filterInputReadOnlySaved(Entity $entity, stdClass $data): void
    {
        $processor = $this->injectableFactory->create(InputFilterProcessor::class);

        $processor->process($entity, $data);
    }
}
