<?php

declare(strict_types=1);

/*
 * Chill is a software for social workers
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace Chill\MainBundle\CRUD\Controller;

use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Serializer\Model\Collection;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Serializer\Exception\NotEncodableValueException;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;

class ApiController extends AbstractCRUDController
{
    /**
     * Base method for handling api action.
     *
     * @return void
     */
    public function entityApi(Request $request, mixed $id, ?string $_format = 'json'): Response
    {
        return match ($request->getMethod()) {
            Request::METHOD_GET, Request::METHOD_HEAD => $this->entityGet('_entity', $request, $id, $_format),
            Request::METHOD_PUT, Request::METHOD_PATCH => $this->entityPut('_entity', $request, $id, $_format),
            Request::METHOD_POST => $this->entityPostAction('_entity', $request, $id),
            Request::METHOD_DELETE => $this->entityDelete('_entity', $request, $id, $_format),
            default => throw new BadRequestHttpException('This method is not implemented'),
        };
    }

    public function entityDelete($action, Request $request, $id, string $_format): Response
    {
        $entity = $this->getEntity($action, $id, $request);

        if (null === $entity) {
            throw $this->createNotFoundException(sprintf('The %s with id %s is not found', $this->getCrudName(), $id));
        }

        $response = $this->checkACL($action, $request, $_format, $entity);

        if ($response instanceof Response) {
            return $response;
        }

        $response = $this->onPostCheckACL($action, $request, $_format, $entity);

        if ($response instanceof Response) {
            return $response;
        }

        $response = $this->onBeforeSerialize($action, $request, $_format, $entity);

        if ($response instanceof Response) {
            return $response;
        }

        $errors = $this->validate($action, $request, $_format, $entity);

        $response = $this->onAfterValidation($action, $request, $_format, $entity, $errors);

        if ($response instanceof Response) {
            return $response;
        }

        if ($errors->count() > 0) {
            $response = $this->json($errors);
            $response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);

            return $response;
        }

        $this->getManagerRegistry()->getManager()->remove($entity);
        $this->getManagerRegistry()->getManager()->flush();

        $response = $this->onAfterFlush($action, $request, $_format, $entity, $errors);

        if ($response instanceof Response) {
            return $response;
        }

        return $this->json(Response::HTTP_OK);
    }

    public function entityPost(Request $request, $_format): Response
    {
        return match ($request->getMethod()) {
            Request::METHOD_POST => $this->entityPostAction('_entity', $request, $_format),
            default => throw new BadRequestHttpException('This method is not implemented'),
        };
    }

    public function entityPut($action, Request $request, $id, string $_format): Response
    {
        $entity = $this->getEntity($action, $id, $request);

        $postFetch = $this->onPostFetchEntity($action, $request, $entity, $_format);

        if ($postFetch instanceof Response) {
            return $postFetch;
        }

        if (null === $entity) {
            throw $this->createNotFoundException(sprintf('The %s with id %s is not found', $this->getCrudName(), $id));
        }

        $response = $this->checkACL($action, $request, $_format, $entity);

        if ($response instanceof Response) {
            return $response;
        }

        $response = $this->onPostCheckACL($action, $request, $_format, $entity);

        if ($response instanceof Response) {
            return $response;
        }

        $response = $this->onBeforeSerialize($action, $request, $_format, $entity);

        if ($response instanceof Response) {
            return $response;
        }

        try {
            $entity = $this->deserialize($action, $request, $_format, $entity);
        } catch (NotEncodableValueException $e) {
            throw new BadRequestHttpException('invalid json', $e, 400);
        }

        $errors = $this->validate($action, $request, $_format, $entity);

        $response = $this->onAfterValidation($action, $request, $_format, $entity, $errors);

        if ($response instanceof Response) {
            return $response;
        }

        if ($errors->count() > 0) {
            $response = $this->json($errors);
            $response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);

            return $response;
        }

        $this->getManagerRegistry()->getManagerForClass($this->getEntityClass())->flush();

        $response = $this->onAfterFlush($action, $request, $_format, $entity, $errors);

        if ($response instanceof Response) {
            return $response;
        }

        return $this->json(
            $entity,
            Response::HTTP_OK,
            [],
            $this->getContextForSerializationPostAlter($action, $request, $_format, $entity)
        );
    }

    /**
     * Base action for indexing entities.
     */
    public function indexApi(Request $request, string $_format)
    {
        return match ($request->getMethod()) {
            Request::METHOD_GET, Request::METHOD_HEAD => $this->indexApiAction('_index', $request, $_format),
            default => throw $this->createNotFoundException('This method is not supported'),
        };
    }

    public function onBeforeSerialize(string $action, Request $request, $_format, $entity): ?Response
    {
        return null;
    }

    /**
     * Add or remove an associated entity, using `add` and `remove` methods.
     *
     * This method:
     *
     * 1. Fetch the base entity (throw 404 if not found)
     * 2. checkACL,
     * 3. run onPostCheckACL, return response if any,
     * 4. deserialize posted data into the entity given by $postedDataType, with the context in $postedDataContext
     * 5. run 'add+$property' for POST method, or 'remove+$property' for DELETE method
     * 6. validate the base entity (not the deserialized one). Groups are fetched from getValidationGroups, validation is perform by `validate`
     * 7. run onAfterValidation
     * 8. if errors, return a 422 response with errors
     * 9. if $forcePersist === true, persist the entity
     * 10. flush the data
     * 11. run onAfterFlush
     * 12. return a 202 response for DELETE with empty body, or HTTP 200 for post with serialized posted entity
     *
     * @param string action
     * @param mixed id
     * @param string $property          the name of the property. This will be used to make a `add+$property` and `remove+$property` method
     * @param string $postedDataType    the type of the posted data (the content)
     * @param string $postedDataContext a context to deserialize posted data (the content)
     * @param bool   $forcePersist      force to persist the created element (only for POST request)
     *
     * @throw BadRequestException if unable to deserialize the posted data
     * @throw BadRequestException if the method is not POST or DELETE
     */
    protected function addRemoveSomething(string $action, mixed $id, Request $request, string $_format, string $property, string $postedDataType, array $postedDataContext = [], bool $forcePersist = false): Response
    {
        $entity = $this->getEntity($action, $id, $request);

        $postFetch = $this->onPostFetchEntity($action, $request, $entity, $_format);

        if ($postFetch instanceof Response) {
            return $postFetch;
        }

        $response = $this->checkACL($action, $request, $_format, $entity);

        if ($response instanceof Response) {
            return $response;
        }

        $response = $this->onPostCheckACL($action, $request, $_format, $entity);

        if ($response instanceof Response) {
            return $response;
        }

        $response = $this->onBeforeSerialize($action, $request, $_format, $entity);

        if ($response instanceof Response) {
            return $response;
        }

        try {
            $postedData = $this->getSerializer()->deserialize($request->getContent(), $postedDataType, $_format, $postedDataContext);
        } catch (\Symfony\Component\Serializer\Exception\UnexpectedValueException $e) {
            throw new BadRequestHttpException(sprintf('Unable to deserialize posted data: %s', $e->getMessage()), $e, 0);
        }

        match ($request->getMethod()) {
            /* @phpstan-ignore-next-line */
            Request::METHOD_DELETE => $entity->{'remove'.\ucfirst($property)}($postedData),
            /* @phpstan-ignore-next-line */
            Request::METHOD_POST => $entity->{'add'.\ucfirst($property)}($postedData),
            default => throw new BadRequestHttpException('this method is not supported'),
        };

        $errors = $this->validate($action, $request, $_format, $entity, [$postedData]);

        $response = $this->onAfterValidation($action, $request, $_format, $entity, $errors, [$postedData]);

        if ($response instanceof Response) {
            return $response;
        }

        if ($errors->count() > 0) {
            // only format accepted
            return $this->json($errors, 422);
        }

        if ($forcePersist && Request::METHOD_POST === $request->getMethod()) {
            $this->getManagerRegistry()->getManager()->persist($postedData);
        }

        $this->getManagerRegistry()->getManager()->flush();

        $response = $this->onAfterFlush($action, $request, $_format, $entity, $errors, [$postedData]);

        if ($response instanceof Response) {
            return $response;
        }

        return match ($request->getMethod()) {
            Request::METHOD_DELETE => $this->json('', Response::HTTP_OK),
            Request::METHOD_POST => $this->json(
                $postedData,
                Response::HTTP_OK,
                [],
                $this->getContextForSerializationPostAlter($action, $request, $_format, $entity, [$postedData])
            ),
            default => throw new \Exception('Unable to handle such request method.'),
        };
    }

    /**
     * Deserialize the content of the request into the class associated with the curd.
     *
     * @param mixed|null $entity
     */
    protected function deserialize(string $action, Request $request, string $_format, $entity = null): object
    {
        $default = [];

        if (null !== $entity) {
            $default[AbstractNormalizer::OBJECT_TO_POPULATE] = $entity;
        }

        $context = \array_merge(
            $default,
            $this->getContextForSerialization($action, $request, $_format, $entity)
        );

        return $this->getSerializer()->deserialize($request->getContent(), $this->getEntityClass(), $_format, $context);
    }

    /**
     * The view action.
     *
     * Some steps may be overriden during this process of rendering:
     *
     * This method:
     *
     * 1.  fetch the entity, using `getEntity`
     * 2.  launch `onPostFetchEntity`. If postfetch is an instance of Response,
     *     this response is returned.
     * 2.  throw an HttpNotFoundException if entity is null
     * 3.  check ACL using `checkACL` ;
     * 4.  launch `onPostCheckACL`. If the result is an instance of Response,
     *     this response is returned ;
     * 5.  Serialize the entity and return the result. The serialization context is given by `getSerializationContext`
     */
    protected function entityGet(string $action, Request $request, mixed $id, mixed $_format = 'html'): Response
    {
        $entity = $this->getEntity($action, $id, $request);

        $postFetch = $this->onPostFetchEntity($action, $request, $entity, $_format);

        if ($postFetch instanceof Response) {
            return $postFetch;
        }

        $response = $this->checkACL($action, $request, $_format, $entity);

        if ($response instanceof Response) {
            return $response;
        }

        $response = $this->onPostCheckACL($action, $request, $_format, $entity);

        if ($response instanceof Response) {
            return $response;
        }

        $response = $this->onBeforeSerialize($action, $request, $_format, $entity);

        if ($response instanceof Response) {
            return $response;
        }

        if ('json' === $_format) {
            $context = $this->getContextForSerialization($action, $request, $_format, $entity);

            return $this->json($entity, Response::HTTP_OK, [], $context);
        }

        throw new BadRequestHttpException('This format is not implemented');
    }

    protected function entityPostAction($action, Request $request, string $_format): Response
    {
        $entity = $this->createEntity($action, $request);

        try {
            $entity = $this->deserialize($action, $request, $_format, $entity);
        } catch (NotEncodableValueException $e) {
            throw new BadRequestHttpException('invalid json', $e, 400);
        }

        $errors = $this->validate($action, $request, $_format, $entity);

        $response = $this->onAfterValidation($action, $request, $_format, $entity, $errors);

        if ($response instanceof Response) {
            return $response;
        }

        if ($errors->count() > 0) {
            $response = $this->json($errors);
            $response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);

            return $response;
        }

        $response = $this->checkACL($action, $request, $_format, $entity);

        if ($response instanceof Response) {
            return $response;
        }

        $response = $this->onPostCheckACL($action, $request, $_format, $entity);

        if ($response instanceof Response) {
            return $response;
        }

        $this->getManagerRegistry()->getManager()->persist($entity);
        $this->getManagerRegistry()->getManager()->flush();

        $response = $this->onAfterFlush($action, $request, $_format, $entity, $errors);

        if ($response instanceof Response) {
            return $response;
        }
        $response = $this->onBeforeSerialize($action, $request, $_format, $entity);

        if ($response instanceof Response) {
            return $response;
        }

        return $this->json(
            $entity,
            Response::HTTP_OK,
            [],
            $this->getContextForSerializationPostAlter($action, $request, $_format, $entity)
        );
    }

    protected function getContextForSerialization(string $action, Request $request, string $_format, $entity): array
    {
        return match ($request->getMethod()) {
            Request::METHOD_GET => ['groups' => ['read']],
            Request::METHOD_PUT, Request::METHOD_PATCH, Request::METHOD_POST => ['groups' => ['write']],
            default => throw new \LogicException('get context for serialization is not implemented for this method'),
        };
    }

    /**
     * Get the context for serialization post alter query (in case of
     * PATCH, PUT, or POST method).
     *
     * This is called **after** the entity was altered.
     */
    protected function getContextForSerializationPostAlter(string $action, Request $request, string $_format, mixed $entity, array $more = []): array
    {
        return ['groups' => ['read']];
    }

    protected function getSerializer(): SerializerInterface
    {
        return $this->get('serializer');
    }

    protected function getValidationGroups(string $action, Request $request, string $_format, $entity): ?array
    {
        return null;
    }

    /**
     * Build an index page.
     *
     * Some steps may be overriden during this process of rendering.
     *
     * This method:
     *
     * 1.  Launch `onPreIndex`
     * x.  check acl. If it does return a response instance, return it
     * x.  launch `onPostCheckACL`. If it does return a response instance, return it
     * 1.  count the items, using `countEntities`
     * 2.  build a paginator element from the the number of entities ;
     * 3.  Launch `onPreIndexQuery`. If it does return a response instance, return it
     * 3.  build a query, using `queryEntities`
     * x.  fetch the results, using `getQueryResult`
     * x.  Launch `onPostIndexFetchQuery`. If it does return a response instance, return it
     * 4.  Serialize the entities in a Collection, using `SerializeCollection`
     *
     * @param string $action
     */
    protected function indexApiAction($action, Request $request, mixed $_format)
    {
        $this->onPreIndex($action, $request, $_format);

        $response = $this->checkACL($action, $request, $_format);

        if ($response instanceof Response) {
            return $response;
        }

        $entity = '';

        $response = $this->onPostCheckACL($action, $request, $_format, $entity);

        if ($response instanceof Response) {
            return $response;
        }

        $totalItems = $this->countEntities($action, $request, $_format);
        $paginator = $this->getPaginatorFactory()->create($totalItems);

        $response = $this->onPreIndexBuildQuery(
            $action,
            $request,
            $_format,
            $totalItems,
            $paginator
        );

        if ($response instanceof Response) {
            return $response;
        }

        $query = $this->queryEntities($action, $request, $_format, $paginator);

        $response = $this->onPostIndexBuildQuery(
            $action,
            $request,
            $_format,
            $totalItems,
            $paginator,
            $query
        );

        if ($response instanceof Response) {
            return $response;
        }

        $entities = $this->getQueryResult($action, $request, $_format, $totalItems, $paginator, $query);

        $response = $this->onPostIndexFetchQuery(
            $action,
            $request,
            $_format,
            $totalItems,
            $paginator,
            $entities
        );

        if ($response instanceof Response) {
            return $response;
        }

        return $this->serializeCollection($action, $request, $_format, $paginator, $entities);
    }

    protected function onAfterFlush(string $action, Request $request, string $_format, $entity, ConstraintViolationListInterface $errors, array $more = []): ?Response
    {
        return null;
    }

    protected function onAfterValidation(string $action, Request $request, string $_format, $entity, ConstraintViolationListInterface $errors, array $more = []): ?Response
    {
        return null;
    }

    /**
     * Serialize collections.
     */
    protected function serializeCollection(string $action, Request $request, string $_format, PaginatorInterface $paginator, mixed $entities): Response
    {
        $model = new Collection($entities, $paginator);

        $context = $this->getContextForSerialization($action, $request, $_format, $entities);

        return $this->json($model, Response::HTTP_OK, [], $context);
    }

    protected function validate(string $action, Request $request, string $_format, $entity, array $more = []): ConstraintViolationListInterface
    {
        $validationGroups = $this->getValidationGroups($action, $request, $_format, $entity);

        return $this->getValidator()->validate($entity, null, $validationGroups);
    }
}
