<?php
namespace Newland\Toubiz\Sync\Neos\Domain\Repository;

/*
 * This file is part of the "toubiz-sync-neos" package.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 */

use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\Persistence\Doctrine\Repository;
use Neos\Flow\Persistence\QueryResultInterface;
use Newland\Toubiz\Sync\Neos\Domain\Filter\FilterInterface;
use Newland\Toubiz\Sync\Neos\Domain\Model\AbstractEntity;
use Newland\Toubiz\Sync\Neos\Service\SortingService;
use Webit\DoctrineORM\QueryBuilder\Iterator\QueryBuilderIterator;

/**
 * Abstract repository.
 *
 * @Flow\Scope("singleton")
 *
 * @method AbstractEntity|null findOneByOriginalId(string $originalId)
 */
abstract class AbstractRepository extends Repository
{
    /**
     * @var SortingService
     * @Flow\Inject()
     */
    protected $sortingService;

    /** @var string */
    protected $alias = '';

    /**
     * NULL is usually used to refer to the fact that an article does not have any client
     * associated with it. However, for backwards compatibility there are a couple of special
     * client values that should also be interpreted as empty.
     *
     * @var string[]
     */
    protected $nonNullEmptyClientValues = [ '', 'default' ];

    /**
     * @param FilterInterface $filter
     * @param QueryBuilder $query
     * @return QueryBuilder
     */
    abstract protected function applyFilter(FilterInterface $filter, QueryBuilder $query): QueryBuilder;

    public function findByIdentifiers(array $identifiers): iterable
    {
        if (empty($identifiers)) {
            return [];
        }

        $queryBuilder = $this->createQueryBuilder($this->getTableAlias());

        return $queryBuilder
            ->select()
            ->where($queryBuilder->expr()->in($this->getTableAlias() . '.Persistence_Object_Identifier', $identifiers))
            ->getQuery()
            ->execute();
    }

    protected function queryBuilderForOriginalIdQuery(string $alias): QueryBuilder
    {
        return $this->createQueryBuilder($alias);
    }

    /**
     * @return AbstractEntity|null
     */
    public function findOneByOriginalIdAndClient(string $originalId, string $client)
    {
        $query = $this->queryBuilderForOriginalIdQuery('entity');
        $query
            ->andWhere($query->expr()->eq('entity.originalId', ':originalId'))
            ->andWhere($query->expr()->eq('entity.client', ':client'))
            ->setParameters(compact('originalId', 'client'))
            ->setMaxResults(1);

        $result = $query->getQuery()->execute();
        if (!empty($result)) {
            return $result[0];
        }

        return $this->findOneByOriginalIdAndEmptyClient($originalId);
    }

    /**
     * @return AbstractEntity|null
     */
    protected function findOneByOriginalIdAndEmptyClient(string $originalId)
    {
        $query = $this->queryBuilderForOriginalIdQuery('entity');
        $query
            ->andWhere($query->expr()->eq('entity.originalId', ':originalId'))
            ->setParameter('originalId', $originalId)
            ->andWhere(
                $query->expr()->orX(
                    $query->expr()->isNull('entity.client'),
                    $query->expr()->in('entity.client', $this->nonNullEmptyClientValues)
                )
            )
            ->setMaxResults(1);

        $result = $query->getQuery()->execute();
        return empty($result) ? null : $result[0];
    }

    /**
     * Counts items and pages for given filter.
     *
     * @param FilterInterface $filter
     * @param int $perPage
     * @return array
     */
    public function countByFilter(FilterInterface $filter, $perPage)
    {
        $query = $this->queryForFilter($filter);

        // Reset pagination parameters.
        $query->setMaxResults(null);
        $query->setFirstResult(0);

        $query->select('COUNT(DISTINCT ' . $this->getTableAlias() . ') as total');
        $result = $query->getQuery()->execute()[0];
        $pages = (int) floor($result['total'] / $perPage) + ($result['total'] % $perPage === 0 ? 0 : 1);
        return [
            'items' => (int) $result['total'],
            'pages' => $pages === 0 ? 1 : $pages,
        ];
    }

    /**
     * Find entities matching given filter.
     *
     * @param FilterInterface $filter
     * @return QueryResultInterface
     */
    public function findByFilter(FilterInterface $filter)
    {
        return $this->queryForFilter($filter)
            ->getQuery()
            ->execute();
    }

    /**
     * @param FilterInterface $filter
     * @return AbstractEntity|null
     */
    public function findOneByFilter(FilterInterface $filter): ?AbstractEntity
    {
        return $this->queryForFilter($filter)
                ->getQuery()
                ->setMaxResults(1)
                ->execute()[0] ?? null;
    }

    /**
     * Note: This method changes the return type from it's parent from Entity[] to \Iterator<Entity>
     *
     * @return \Iterator
     */
    public function findAll()
    {
        return new QueryBuilderIterator(
            $this->createQueryBuilder($this->getTableAlias()),
            100
        );
    }

    public function queryForFilter(FilterInterface $filter): QueryBuilder
    {
        return $this->applyFilter(
            $filter,
            $this->createQueryBuilder($this->getTableAlias())->distinct(true)
        );
    }

    public function forEachFilter(FilterInterface $filter, callable $block, int $pageSize = 50): void
    {
        $offset = 0;
        $max = (int) $this->countByFilter($filter, 1)['items'];

        $query = $this->queryForFilter($filter)->getQuery();
        $query->setMaxResults($pageSize);

        while ($offset < $max) {
            $query->setFirstResult($offset);

            foreach ($query->execute() as $entity) {
                $block($entity);
            }

            $offset += $pageSize;
        }
    }

    /**
     * Removes all records from given array of ids.
     *
     * @param array $ids
     * @return void
     */
    public function removeByIds(array $ids, callable $callback = null): void
    {
        foreach (array_chunk($ids, 100) as $idChunk) {
            $articles = $this->findByIdentifiers($idChunk);

            foreach ($articles as $article) {
                $this->remove($article);
                if ($callback) {
                    $callback($article);
                }
            }
            $this->persistenceManager->persistAll();
        }
    }

    /**
     * Applies basic filter functions (from the abstract filter) onto
     * the given query builder.
     *
     * @param FilterInterface $filter
     * @param QueryBuilder $query
     * @return void
     */
    protected function applyBasicFilter(FilterInterface $filter, QueryBuilder $query)
    {
        foreach ($filter->getLeftJoins() as $field => $table) {
            $query->leftJoin($field, $table);
        }

        foreach ($filter->getInnerJoins() as $field => $table) {
            $query->innerJoin($field, $table);
        }

        foreach ($filter->getOrderBy() as $field => $order) {
            $query->addOrderBy($field, $order);
        }

        foreach ($filter->getGroupBy() as $field) {
            $query->addGroupBy($field);
        }

        $limit = $filter->getLimit();
        if ($limit !== null) {
            $query->setMaxResults($limit);
        }

        $offset = $filter->getOffset();
        if ($offset !== null) {
            $query->setFirstResult($offset);
        }

        $excludes = array_map(
            function (AbstractEntity $entity) {
                return $entity->getOriginalId();
            },
            $filter->getExcludes()
        );
        if (!empty($excludes)) {
            $field = $this->getTableAlias() . '.originalId';
            $query->andWhere($query->expr()->notIn($field, $excludes));
        }

        foreach ($filter->getOrderByRelation() as $order) {
            $query->innerJoin($order['relation'], $order['alias'])
                ->groupBy('category.Persistence_Object_Identifier')
                ->orderBy('COUNT(' . $order['alias'] . '.Persistence_Object_Identifier)', $order['direction']);
        }
    }

    /**
     * Helper for getting the table alias used inside queries.
     *
     * This may be a temporary solution!
     *
     * @return string
     */
    protected function getTableAlias(): string
    {
        $parts = explode('\\', get_class($this));
        $name = array_slice($parts, -1)[0];
        return lcfirst(str_replace('Repository', '', $name));
    }

    /**
     * Ensures sorting defined on the backend
     *
     * @return array
     */
    public function getSorted(FilterInterface $filter, string $fieldName, array $sorting): array
    {

        $objects = $this->findByFilter($filter);

        return $this->sortingService->sortObjects(
            (array) $objects,
            $fieldName,
            $sorting
        );
    }

    public function removeOneWhere(array $conditions): bool
    {
        $query = $this->createQueryBuilder('entity');
        foreach ($conditions as $field => $value) {
            $dbField = sprintf('entity.%s', $field);
            $placeholder = sprintf(':%s', $field);
            $query->andWhere($query->expr()->eq($dbField, $placeholder));
        }
        $query->setParameters($conditions);
        $entity = $query->setMaxResults(1)->getQuery()->execute()[0] ?? null;

        if ($entity) {
            $this->remove($entity);
            return true;
        }

        return false;
    }

    public function getAllIdentifiers(): array
    {
        $query = $this->createQueryBuilder('entity');
        $query->select('entity.Persistence_Object_Identifier as id');
        return array_column($query->getQuery()->execute([], Query::HYDRATE_ARRAY), 'id');
    }

    /**
     * Simple abstraction over the `add` and `update` methods that figures out
     * if the given entity has been persisted before.
     *
     * @param object $entity
     */
    public function store($entity): void
    {
        if ($this->persistenceManager->isNewObject($entity)) {
            $this->add($entity);
        } else {
            $this->update($entity);
        }
    }

    /**
     * @param mixed $entity
     * @return mixed
     */
    public function fresh($entity)
    {
        return $this->findByIdentifier(
            $this->persistenceManager->getIdentifierByObject($entity)
        );
    }

    public function removeMultiple(iterable $multiple): void
    {
        foreach ($multiple as $entity) {
            $this->remove($entity);
        }
    }
}
