<?php

namespace Mautic\ApiBundle\Controller;

use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Doctrine\Persistence\ManagerRegistry;
use FOS\RestBundle\Controller\AbstractFOSRestController;
use FOS\RestBundle\View\View;
use JMS\Serializer\Exclusion\ExclusionStrategyInterface;
use Mautic\ApiBundle\ApiEvents;
use Mautic\ApiBundle\Event\ApiInitializeEvent;
use Mautic\ApiBundle\Event\ApiSerializationContextEvent;
use Mautic\ApiBundle\Helper\BatchIdToEntityHelper;
use Mautic\ApiBundle\Helper\EntityResultHelper;
use Mautic\ApiBundle\Serializer\Exclusion\ParentChildrenExclusionStrategy;
use Mautic\ApiBundle\Serializer\Exclusion\PublishDetailsExclusionStrategy;
use Mautic\CoreBundle\Controller\FormErrorMessagesTrait;
use Mautic\CoreBundle\Controller\MauticController;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Form\RequestTrait;
use Mautic\CoreBundle\Helper\AppVersion;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\AbstractCommonModel;
use Mautic\CoreBundle\Model\MauticModelInterface;
use Mautic\CoreBundle\Security\Exception\PermissionException;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;

/**
 * @template E of object
 */
class FetchCommonApiController extends AbstractFOSRestController implements MauticController
{
    use RequestTrait;
    use FormErrorMessagesTrait;

    /**
     * If set to true, serializer will not return null values.
     *
     * @var bool
     */
    protected $customSelectRequested = false;

    /**
     * Class for the entity.
     *
     * @var class-string<E>
     */
    protected $entityClass;

    /**
     * Key to return for entity lists.
     *
     * @var string
     */
    protected $entityNameMulti;

    /**
     * Key to return for a single entity.
     *
     * @var string
     */
    protected $entityNameOne;

    /**
     * Custom JMS strategies to add to the view's context.
     *
     * @var array<int, ExclusionStrategyInterface>
     */
    protected $exclusionStrategies = [];

    /**
     * Pass to the model's getEntities() method.
     *
     * @var array<mixed>
     */
    protected $extraGetEntitiesArguments = [];

    /**
     * @var bool
     */
    protected $inBatchMode = false;

    /**
     * Used to set default filters for entity lists such as restricting to owning user.
     *
     * @var array<array<string, mixed>>
     */
    protected $listFilters = [];

    /**
     * Model object for processing the entity.
     *
     * @var AbstractCommonModel<E>|null
     */
    protected $model;

    /**
     * The level parent/children should stop loading if applicable.
     *
     * @var int
     */
    protected $parentChildrenLevelDepth = 3;

    /**
     * Permission base for the entity such as page:pages.
     *
     * @var string|null
     */
    protected $permissionBase;

    /**
     * @var array<int, string>
     */
    protected $serializerGroups = [];

    /**
     * @var Translator
     */
    protected $translator;

    protected ContainerBagInterface $parametersContainer;

    /**
     * @param ModelFactory<E> $modelFactory
     */
    public function __construct(
        protected CorePermissions $security,
        Translator $translator,
        protected EntityResultHelper $entityResultHelper,
        private AppVersion $appVersion,
        private RequestStack $requestStack,
        protected ManagerRegistry $doctrine,
        protected ModelFactory $modelFactory,
        protected EventDispatcherInterface $dispatcher,
        protected CoreParametersHelper $coreParametersHelper,
    ) {
        $this->translator           = $translator;

        if (null !== $this->model && !$this->permissionBase && method_exists($this->model, 'getPermissionBase')) {
            $this->permissionBase = $this->model->getPermissionBase();
        }

        $event = new ApiInitializeEvent(
            (string) $this->entityClass,
            $this->serializerGroups,
            $this->exclusionStrategies,
        );
        $this->dispatcher->dispatch($event);

        $this->serializerGroups    = $event->getSerializerGroups();
        $this->exclusionStrategies = $event->getExclusionStrategies();
    }

    /**
     * Obtains a list of entities as defined by the API URL.
     *
     * @return Response
     */
    public function getEntitiesAction(Request $request, UserHelper $userHelper)
    {
        $repo          = $this->model->getRepository();
        $tableAlias    = $repo->getTableAlias();
        $publishedOnly = $request->get('published', 0);
        $minimal       = $request->get('minimal', 0);

        try {
            if (!$this->security->isGranted($this->permissionBase.':view')) {
                return $this->accessDenied();
            }
        } catch (PermissionException $e) {
            return $this->accessDenied($e->getMessage());
        }

        if ($this->security->checkPermissionExists($this->permissionBase.':viewother')
            && !$this->security->isGranted($this->permissionBase.':viewother')
            && null !== $user = $userHelper->getUser()
        ) {
            $this->listFilters[] = [
                'column' => $tableAlias.'.createdBy',
                'expr'   => 'eq',
                'value'  => $user->getId(),
            ];
        }

        if ($publishedOnly) {
            $this->listFilters[] = [
                'column' => $tableAlias.'.isPublished',
                'expr'   => 'eq',
                'value'  => true,
            ];
        }

        if ($minimal) {
            if (isset($this->serializerGroups[0])) {
                $this->serializerGroups[0] = str_replace('Details', 'List', $this->serializerGroups[0]);
            }
        }

        $args = array_merge(
            [
                'start'  => $request->query->get('start', 0),
                'limit'  => $request->query->get('limit', $this->coreParametersHelper->get('default_pagelimit')),
                'filter' => [
                    'string' => $request->query->get('search', ''),
                    'force'  => $this->listFilters,
                ],
                'orderBy'        => $this->addAliasIfNotPresent($request->query->get('orderBy', ''), $tableAlias),
                'orderByDir'     => $request->query->get('orderByDir', 'ASC'),
                'withTotalCount' => true, // for repositories that break free of Paginator
            ],
            $this->extraGetEntitiesArguments
        );

        if ($select = InputHelper::cleanArray($request->query->all()['select'] ?? $request->request->all()['select'] ?? [])) {
            $args['select']              = $select;
            $this->customSelectRequested = true;
        }

        if ($where = $this->getWhereFromRequest($request)) {
            $args['filter']['where'] = $where;
        }

        if ($order = $this->getOrderFromRequest($request)) {
            $args['filter']['order'] = $order;
        }

        if ($totalCountTtl = $this->getTotalCountTtl()) {
            $args['totalCountTtl'] = $totalCountTtl;
        }

        $results = $this->model->getEntities($args);

        [$entities, $totalCount] = $this->prepareEntitiesForView($results);

        $view = $this->view(
            [
                'total'                => $totalCount,
                $this->entityNameMulti => $entities,
            ],
            Response::HTTP_OK
        );
        $this->setSerializationContext($view);

        return $this->handleView($view);
    }

    /**
     * Sanitizes and returns an array of where statements from the request.
     *
     * @return array<mixed>
     */
    protected function getWhereFromRequest(Request $request)
    {
        $where = $request->query->all()['where'] ?? [];

        $this->sanitizeWhereClauseArrayFromRequest($where);

        return $where;
    }

    /**
     * Sanitizes and returns an array of ORDER statements from the request.
     *
     * @return array<mixed>
     */
    protected function getOrderFromRequest(Request $request): array
    {
        return InputHelper::cleanArray($request->query->all()['order'] ?? []);
    }

    /**
     * Adds the repository alias to the column name if it doesn't exist.
     *
     * @return string $column name with alias prefix
     */
    protected function addAliasIfNotPresent(string $columns, string $alias): string
    {
        if (!$columns) {
            return $columns;
        }

        $columns = explode(',', trim($columns));
        $prefix  = $alias.'.';

        array_walk(
            $columns,
            function (&$column, $key, $prefix): void {
                $column = trim($column);
                if (1 === count(explode('.', $column))) {
                    $column = $prefix.$column;
                }
            },
            $prefix
        );

        return implode(',', $columns);
    }

    /**
     * Obtains a specific entity as defined by the API URL.
     *
     * @param int $id Entity ID
     *
     * @return Response
     */
    public function getEntityAction(Request $request, $id)
    {
        $args = [];
        if ($select = InputHelper::cleanArray($request->get('select', []))) {
            $args['select']              = $select;
            $this->customSelectRequested = true;
        }

        if (!empty($args)) {
            $args['id'] = $id;
            $entity     = $this->model->getEntity($args);
        } else {
            $entity = $this->model->getEntity($id);
        }

        if (!$entity instanceof $this->entityClass) {
            return $this->notFound();
        }

        if (!$this->checkEntityAccess($entity)) {
            return $this->accessDenied();
        }

        $this->preSerializeEntity($entity);
        $view = $this->view([$this->entityNameOne => $entity], Response::HTTP_OK);
        $this->setSerializationContext($view);

        return $this->handleView($view);
    }

    /**
     * Creates new entity from provided params.
     *
     * @param array<mixed> $params
     *
     * @return object
     */
    public function getNewEntity(array $params)
    {
        return $this->model->getEntity();
    }

    public function getCurrentRequest(): Request
    {
        $request = $this->requestStack->getCurrentRequest();

        if (null === $request) {
            throw new \RuntimeException('Request is not set.');
        }

        return $request;
    }

    /**
     * Alias for notFound method. It's used in the LeadAccessTrait.
     *
     * @param array<mixed> $args
     *
     * @return Response
     */
    public function postActionRedirect(array $args = [])
    {
        return $this->notFound('mautic.contact.error.notfound');
    }

    /**
     * Returns a 403 Access Denied.
     *
     * @param string $msg
     *
     * @return Response
     */
    protected function accessDenied($msg = 'mautic.core.error.accessdenied')
    {
        return $this->returnError($msg, Response::HTTP_FORBIDDEN);
    }

    protected function addExclusionStrategy(ExclusionStrategyInterface $strategy): void
    {
        $this->exclusionStrategies[] = $strategy;
    }

    /**
     * Returns a 400 Bad Request.
     *
     * @param string $msg
     *
     * @return Response
     */
    protected function badRequest($msg = 'mautic.core.error.badrequest')
    {
        return $this->returnError($msg, Response::HTTP_BAD_REQUEST);
    }

    /**
     * Checks if user has permission to access retrieved entity.
     *
     * @param FormEntity $entity
     * @param string     $action view|create|edit|publish|delete
     *
     * @return bool|Response
     */
    protected function checkEntityAccess($entity, $action = 'view')
    {
        $ownPerm   = "{$this->permissionBase}:{$action}own";
        $otherPerm = "{$this->permissionBase}:{$action}other";

        if ('publish' === $action) {
            return $this->security->hasPublishAccessForEntity($entity, $ownPerm, $otherPerm);
        }

        if ('create' !== $action && is_object($entity) && method_exists($entity, 'getCreatedBy')) {
            $owner = (method_exists($entity, 'getPermissionUser')) ? $entity->getPermissionUser() : $entity->getCreatedBy();

            return $this->security->hasEntityAccess($ownPerm, $otherPerm, $owner);
        }

        try {
            return $this->security->isGranted("{$this->permissionBase}:{$action}");
        } catch (PermissionException $e) {
            return $this->accessDenied($e->getMessage());
        }
    }

    /**
     * @param mixed[]                   $parameters
     * @param mixed[]                   $errors
     * @param bool                      $prepareForSerialization
     * @param string                    $requestIdColumn
     * @param MauticModelInterface|null $model
     * @param bool                      $returnWithOriginalKeys
     *
     * @return mixed[]
     */
    protected function getBatchEntities($parameters, &$errors, $prepareForSerialization = false, $requestIdColumn = 'id', $model = null, $returnWithOriginalKeys = true): array
    {
        $idHelper = new BatchIdToEntityHelper($parameters, $requestIdColumn);

        if (!$idHelper->hasIds()) {
            return [];
        }

        /** @var AbstractCommonModel<object> $model */
        $model    = $model ?: $this->model;
        $entities = $model->getEntities(
            [
                'filter' => [
                    'force' => [
                        [
                            'column' => $model->getRepository()->getTableAlias().'.id',
                            'expr'   => 'in',
                            'value'  => $idHelper->getIds(),
                        ],
                    ],
                ],
                'ignore_paginator' => true,
            ]
        );
        // It must be associative because the order of entities has changed
        $idHelper->setIsAssociative(true);

        [$entities, $total] = $prepareForSerialization
                ?
                $this->prepareEntitiesForView($entities)
                :
                $this->prepareEntityResultsToArray($entities);

        // Set errors
        if ($idHelper->hasErrors()) {
            foreach ($idHelper->getErrors() as $key => $error) {
                $this->setBatchError($key, $error, Response::HTTP_BAD_REQUEST, $errors);
            }
        }

        // Return the response with matching keys from the request
        if ($returnWithOriginalKeys) {
            if ($entities instanceof \ArrayObject) {
                $entities = $entities->getArrayCopy();
            }

            return $idHelper->orderByOriginalKey($entities);
        }

        // Return the response with IDs as keys (default behavior)
        $return = [];
        foreach ($entities as $entity) {
            $return[$entity->getId()] = $entity;
        }

        return $return;
    }

    /**
     * Get the default properties of an entity and parents.
     *
     * @phpstan-param E $entity
     *
     * @return array<mixed>
     */
    protected function getEntityDefaultProperties(object $entity): array
    {
        $class         = $entity::class;
        $chain         = array_reverse(class_parents($entity), true) + [$class => $class];
        $defaultValues = [];

        $classMetdata = new ClassMetadata($class);
        foreach ($chain as $class) {
            if (method_exists($class, 'loadMetadata')) {
                $class::loadMetadata($classMetdata);
            }
            $defaultValues += (new \ReflectionClass($class))->getDefaultProperties();
        }

        // These are the mapped columns
        $fields = $classMetdata->getFieldNames();

        // Merge values in with $fields
        $properties = [];
        foreach ($fields as $field) {
            $properties[$field] = $defaultValues[$field];
        }

        return $properties;
    }

    /**
     * Append options to the form.
     *
     * @return array<string, mixed>
     */
    protected function getEntityFormOptions(): array
    {
        return [];
    }

    /**
     * Get a model instance from the service container.
     *
     * @return AbstractCommonModel<E>
     */
    protected function getModel(string $modelNameKey): AbstractCommonModel
    {
        return $this->modelFactory->getModel($modelNameKey);
    }

    /**
     * Returns a 404 Not Found.
     *
     * @return Response
     */
    protected function notFound(string $msg = 'mautic.core.error.notfound')
    {
        return $this->returnError($msg, Response::HTTP_NOT_FOUND);
    }

    /**
     * Gives child controllers opportunity to analyze and do whatever to an entity before going through serializer.
     *
     * @phpstan-param E $entity
     */
    protected function preSerializeEntity(object $entity, string $action = 'view'): void
    {
    }

    /**
     * Prepares entities returned from repository getEntities().
     *
     * @param array<mixed>|Paginator<E> $results
     *
     * @return array{0: array<mixed>|\ArrayObject<int,mixed>, 1: int}
     */
    protected function prepareEntitiesForView($results): array
    {
        return $this->prepareEntityResultsToArray(
            $results,
            function ($entity): void {
                $this->preSerializeEntity($entity);
            }
        );
    }

    /**
     * @param array<mixed>|Paginator<E> $results
     * @param callable|null             $callback
     *
     * @return array{0: array<mixed>|\ArrayObject<int,mixed>, 1: int}
     */
    protected function prepareEntityResultsToArray($results, $callback = null): array
    {
        if (is_array($results) && isset($results['count'])) {
            $totalCount = $results['count'];
            $results    = $results['results'];
        } else {
            $totalCount = count($results);
        }

        $entities = $this->entityResultHelper->getArray($results, $callback);

        return [$entities, $totalCount];
    }

    /**
     * Returns an error.
     *
     * @param array<mixed> $details
     *
     * @return Response|array<string, array<mixed>|int|string|null>
     */
    protected function returnError(string $msg, int $code = Response::HTTP_INTERNAL_SERVER_ERROR, array $details = [])
    {
        if ($this->translator->hasId($msg, 'flashes')) {
            $msg = $this->translator->trans($msg, [], 'flashes');
        } elseif ($this->translator->hasId($msg, 'messages')) {
            $msg = $this->translator->trans($msg, [], 'messages');
        }

        $error = [
            'code'    => $code,
            'message' => $msg,
            'details' => $details,
            'type'    => null,
        ];

        if ($this->inBatchMode) {
            return $error;
        }

        $view = $this->view(
            [
                'errors' => [
                    $error,
                ],
            ],
            $code
        );

        return $this->handleView($view);
    }

    /**
     * @param array<mixed> $where
     */
    protected function sanitizeWhereClauseArrayFromRequest(array &$where): void
    {
        foreach ($where as $key => $statement) {
            if (isset($statement['internal'])) {
                unset($where[$key]);
            } elseif (in_array($statement['expr'], ['andX', 'orX'])) {
                $this->sanitizeWhereClauseArrayFromRequest($statement['val']);
            }
        }
    }

    /**
     * @param array<int, array<string|int>> $errors
     * @param array<int, object|null>       $entities
     *
     * @phpstan-param E|null $entity
     * @phpstan-param array<int, E|null> $entities
     */
    protected function setBatchError(int $key, string $msg, int $code, array &$errors, array &$entities = [], ?object $entity = null): void
    {
        unset($entities[$key]);
        if ($entity) {
            $this->doctrine->getManager()->detach($entity);
        }

        $errors[$key] = [
            'message' => $this->translator->hasId($msg, 'flashes') ? $this->translator->trans($msg, [], 'flashes') : $msg,
            'code'    => $code,
            'type'    => 'api',
        ];
    }

    /**
     * Set serialization groups and exclusion strategies.
     */
    protected function setSerializationContext(View $view): void
    {
        $context = $view->getContext();

        if ($this->dispatcher->hasListeners(ApiEvents::API_PRE_SERIALIZATION_CONTEXT)) {
            $apiSerializationContextEvent = new ApiSerializationContextEvent($context, $this->getCurrentRequest());
            $this->dispatcher->dispatch($apiSerializationContextEvent, ApiEvents::API_PRE_SERIALIZATION_CONTEXT);
            $context = $apiSerializationContextEvent->getContext();
        }

        if (!empty($this->serializerGroups)) {
            $context->setGroups($this->serializerGroups);
        }

        // Only include FormEntity properties for the top level entity and not the associated entities
        $context->addExclusionStrategy(
            new PublishDetailsExclusionStrategy()
        );

        // Only include first level of children/parents
        if ($this->parentChildrenLevelDepth) {
            $context->addExclusionStrategy(
                new ParentChildrenExclusionStrategy($this->parentChildrenLevelDepth)
            );
        }

        // Add custom exclusion strategies
        foreach ($this->exclusionStrategies as $strategy) {
            $context->addExclusionStrategy($strategy);
        }

        // Include null values if a custom select has not been given
        if (!$this->customSelectRequested) {
            $context->setSerializeNull(true);
        }

        if ($this->dispatcher->hasListeners(ApiEvents::API_POST_SERIALIZATION_CONTEXT)) {
            $apiSerializationContextEvent = new ApiSerializationContextEvent($context, $this->getCurrentRequest());
            $this->dispatcher->dispatch($apiSerializationContextEvent, ApiEvents::API_POST_SERIALIZATION_CONTEXT);
            $context = $apiSerializationContextEvent->getContext();
        }

        $view->setContext($context);
    }

    /**
     * @param array<mixed> $parameters
     *
     * @return array<string, array<mixed>|int|string|null>|bool|Response
     */
    protected function validateBatchPayload(array $parameters)
    {
        $batchLimit = (int) $this->coreParametersHelper->get('api_batch_max_limit', 200);
        if (count($parameters) > $batchLimit) {
            return $this->returnError($this->translator->trans('mautic.api.call.batch_exception', ['%limit%' => $batchLimit]));
        }

        return true;
    }

    /**
     * @param mixed|null                $data
     * @param array<string, string|int> $headers
     */
    protected function view($data = null, ?int $statusCode = null, array $headers = []): View
    {
        if ($data instanceof Paginator) {
            // Get iterator out of Paginator class so that the entities are properly serialized by the serializer
            $data = iterator_to_array($data->getIterator(), true);
        }

        $headers['Mautic-Version'] = $this->appVersion->getVersion();

        return parent::view($data, $statusCode, $headers);
    }

    protected function getTotalCountTtl(): ?int
    {
        return null;
    }
}
