<?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\Common\Collections\Collection;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\Query\Parameter;
use Doctrine\ORM\QueryBuilder;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\Utility\Now;
use Neos\Flow\Persistence\QueryResultInterface;
use Neos\Flow\Validation\ValidatorResolver;
use Newland\Toubiz\Api\ObjectAdapter\Article\ExternalIdSelector;
use Newland\Toubiz\Api\ObjectAdapter\Concern\ArticleConstants;
use Newland\Toubiz\Sync\Neos\Command\OrphanFinder;
use Newland\Toubiz\Sync\Neos\Command\Task\SynchronizationResult;
use Newland\Toubiz\Sync\Neos\Domain\Filter\ArticleFilter;
use Newland\Toubiz\Sync\Neos\Domain\Filter\FilterInterface;
use Newland\Toubiz\Sync\Neos\Domain\Model\Article;
use Newland\Toubiz\Sync\Neos\Service\ArticleClientFilterService;
use Newland\Toubiz\Sync\Neos\Translation\TranslatableRepository;
use Newland\Toubiz\Sync\Neos\Translation\TranslatableRepositoryInterface;
use Newland\Toubiz\Sync\Neos\Utility\StringSanitize;

/**
 * Article repository.
 *
 * @Flow\Scope("singleton")
 *
 * @method Article|null findOneByOriginalId(string $originalId)
 * @method Article|null findOneBy(array $criteria, array $orderBy = null)
 * @method Article|null findByIdentifier(string $identifier)
 * @method Article|null findOneByOriginalIdAndEmptyClient(string $originalId)
 * @method Article|null find(string $identifier)
 * @method Article[] findByFilter(ArticleFilter $filter)
 * @method Article[] findByIdentifiers(array $identifiers)
 */
class ArticleRepository extends AbstractRepository implements
    OrphanFinder,
    TranslatableRepositoryInterface
{
    use TranslatableRepository {
        createQueryBuilder as private createTranslatableQueryBuilder;
    }
    use FindsByOriginalIds;

    const MIN_LATITUDE = -90;
    const MAX_LATITUDE = 90;
    const MIN_LONGITUDE = -180;
    const MAX_LONGITUDE = 180;

    /**
     * @var ArticleClientFilterService
     * @Flow\Inject()
     */
    protected $articleClientFilterService;

    /**
     * @var AttributeRepository
     * @Flow\Inject()
     */
    protected $attributeRepository;

    /**
     * @var ValidatorResolver
     * @Flow\Inject()
     */
    protected $validatorResolver;

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

    public function createQueryBuilder($alias, $indexBy = null, bool $withHidden = false): QueryBuilder
    {
        $query = $this->createTranslatableQueryBuilder($alias, $indexBy);

        if (!$withHidden) {
            $query->addFixedExpression(
                $query->expr()->eq(sprintf('%s.hidden', $alias), 'false')
            );
        }

        return $query;
    }

    /**
     * Specific method for fetching all data for a data source
     * (e.g. multi-select in a neos node type property) in the
     * most performant way.
     *
     * @param ArticleFilter $filter
     * @return array
     */
    public function findAllForDataSource(ArticleFilter $filter): array
    {
        $query = $this->createQueryBuilder('article');
        $query->select(
            [
                'article.name AS label',
                'article.Persistence_Object_Identifier AS value',
                'category.title AS group',
            ]
        )->leftJoin('article.mainCategory', 'category')
            ->addOrderBy('article.name', 'asc')
            ->addOrderBy('category.title', 'asc');

        $this->articleClientFilterService->addClientWhereClause($query, $filter);

        return $query->getQuery()->getScalarResult();
    }

    /**
     * Applies filter functions (from the article filter) onto
     * the given query builder.
     *
     * @param ArticleFilter $filter
     * @param QueryBuilder $query
     * @return QueryBuilder
     */
    protected function applyFilter(FilterInterface $filter, QueryBuilder $query): QueryBuilder
    {
        $this->applyBasicFilter($filter, $query);

        // Category is always joined as e.g. sorting relies on it.
        $query->leftJoin('article.categories', 'category');
        $query->leftJoin('article.mainAddress', 'mainAddress');

        $mainType = $filter->getMainType();
        if ($mainType !== null) {
            $query->andWhere($query->expr()->eq('article.mainType', $mainType));
            if ($this->articleClientFilterService->needsFilter('mainType', $mainType, $filter)) {
                $clientWhere = $this->articleClientFilterService->generateEqExprFor(
                    $query,
                    'mainType',
                    $mainType,
                    $filter,
                    'article'
                );
                if ($clientWhere) {
                    $query->andWhere($clientWhere);
                }
            }
        } else {
            $this->articleClientFilterService->addClientWhereClause($query, $filter);
        }

        $categories = $filter->getCategories();
        if (!empty($categories)) {
            $field = 'category.' . $filter->getCategoriesIdentifierField();
            $query->andWhere($query->expr()->in($field, $categories));
        }

        $zips = $filter->getZips();
        if (!empty($zips)) {
            $query->andWhere(
                $query->expr()->in('mainAddress.zip', $zips)
            );
        }

        if ($filter->getExcludeUnsafeCoordinates()) {
            $query->andWhere(
                $query->expr()->isNotNull('mainAddress.latitude'),
                $query->expr()->isNotNull('mainAddress.longitude'),
                $query->expr()->lte('mainAddress.latitude', static::MAX_LATITUDE),
                $query->expr()->gte('mainAddress.latitude', static::MIN_LATITUDE),
                $query->expr()->lte('mainAddress.longitude', static::MAX_LONGITUDE),
                $query->expr()->gte('mainAddress.longitude', static::MIN_LONGITUDE),
                $query->expr()->neq('mainAddress.latitude', 0),
                $query->expr()->neq('mainAddress.longitude', 0)
            );
        }

        $identifiers = $filter->getIdentifiers();
        if (!empty($identifiers)) {
            $query->andWhere(
                $query->expr()->in('article.Persistence_Object_Identifier', $identifiers)
            );
        }

        $attributesIn = $filter->getAttributesIn();
        if (!empty($attributesIn)) {
            foreach ($attributesIn as $attribute => $values) {
                $subQuery = $this->attributeRepository->getArticlesUsingAttributesInQuery($attribute, $values);
                $query->andWhere($query->expr()->in('article', $subQuery->getDQL()));

                foreach ($subQuery->getParameters() as $parameter) {
                    /** @var Parameter $parameter */
                    $query->setParameter($parameter->getName(), $parameter->getValue());
                }
            }
        }

        $attributesRange = $filter->getAttributesRange();
        if (!empty($attributesRange)) {
            foreach ($attributesRange as $attribute => $config) {
                $subQuery = $this->attributeRepository
                    ->getArticlesUsingAttributesMinMaxQuery($attribute, $config['min'], $config['max']);
                $query->andWhere($query->expr()->in('article', $subQuery->getDQL()));

                foreach ($subQuery->getParameters() as $parameter) {
                    /** @var Parameter $parameter */
                    $query->setParameter($parameter->getName(), $parameter->getValue());
                }
            }
        }

        return $query;
    }

    public function queryForOldRecords(
        \DateInterval $timeToLive,
        int $mainType = null,
        \DateTimeImmutable $now = null
    ): QueryBuilder {
        $now = $now ?? new Now();
        $updatedAt = (clone $now)->sub($timeToLive);

        $query = $this->createQueryBuilder($this->getTableAlias());
        $query
            ->select()
            ->andWhere('article.updatedAt <= :updated_at')
            ->setParameter('updated_at', $updatedAt)
            ->orWhere($query->expr()->isNull('article.updatedAt'));

        if ($mainType !== null) {
            $query->andWhere($query->expr()->eq('article.mainType', $mainType));
        }

        return $query;
    }

    public function orphanQuery(): QueryBuilder
    {
        $query = $this->createQueryBuilder('article');
        $query->andWhere($query->expr()->isNull('article.client'));
        return $query;
    }

    public function findOneByExternalIdSelector(ExternalIdSelector $selector, ?string $client = null): ?Article
    {
        $query = $this->externalIdSelectorQuery($selector)
            ->setMaxResults(1);

        if ($client) {
            $expr = $this->entityManager->getExpressionBuilder();
            $query->andWhere($expr->eq('article.client', ':client'))
                ->setParameter('client', $client);
        }

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

    public function findByExternalIdSelector(ExternalIdSelector $selector): array
    {
        return $this->externalIdSelectorQuery($selector)->getQuery()->execute();
    }

    private function externalIdSelectorQuery(ExternalIdSelector $selector): QueryBuilder
    {
        $expr = $this->entityManager->getExpressionBuilder();
        return $this->createQueryBuilder('article')
            ->join('article.externalIds', 'externalId')
            ->where($expr->eq('externalId.type', ':type'))
            ->andWhere($expr->eq('externalId.id', ':id'))
            ->setParameters(
                [
                    'id' => $selector->getId(),
                    'type' => $selector->getType(),
                ]
            );
    }

    public function findUuidsToDeleteBasedOnSynchronizationResult(
        SynchronizationResult $result,
        int $type,
        string $client,
        ?string $sourceApiVersion = null
    ): array {
        $query = $this->createQueryBuilder('article');
        $conditions = [
            $query->expr()->eq('article.client', ':client'),
            $query->expr()->eq('article.mainType', ':type'),
        ];
        $parameters = [ 'client' => $client, 'type' => $type ];

        if ($sourceApiVersion) {
            $conditions[] = $query->expr()->eq('article.sourceApiVersion', ':sourceApiVersion');
            $parameters['sourceApiVersion'] = $sourceApiVersion;
        }

        $query
            ->select([ 'article.Persistence_Object_Identifier', 'article.originalId' ])
            ->where($query->expr()->andX(...$conditions))
            ->setParameters($parameters);

        $iterator = $query->getQuery()->iterate(null, AbstractQuery::HYDRATE_ARRAY);

        $originalIdsToRetain = (array) $result->getOriginalIdsToRetain();
        $originalIdsToDelete = (array) $result->getOriginalIdsToDelete();


        $uuidsToDelete = [];
        foreach ($iterator as $chunk) {
            foreach ($chunk as $item) {
                $shouldBeDeleted = \in_array($item['originalId'], $originalIdsToDelete, false);
                if (!$shouldBeDeleted && \count($originalIdsToRetain) > 0) {
                    $shouldBeDeleted = !\in_array($item['originalId'], $originalIdsToRetain, false);
                }

                if ($shouldBeDeleted) {
                    $uuidsToDelete[] = $item['Persistence_Object_Identifier'];
                }
            }
        }

        return $uuidsToDelete;
    }

    /**
     * The sourceApiVersion can be passed because the same API could import articles from different
     * versions and they would then delete each other. The sourceSystem itself is not a distinction
     * because sources usually have internal, unrelated client ids.
     */
    public function removeBasedOnSynchronizationResult(
        SynchronizationResult $result,
        int $type,
        string $client,
        string $language,
        callable $onProgress,
        string $sourceApiVersion = null
    ): void {
        $this->withLanguage(
            $language,
            function () use ($result, $type, $client, $onProgress, $sourceApiVersion) {
                $uuidsToDelete = $this->findUuidsToDeleteBasedOnSynchronizationResult(
                    $result,
                    $type,
                    $client,
                    $sourceApiVersion
                );

                $total = \count($uuidsToDelete);
                $deleted = 0;
                $onProgress(0, $total);

                $this->removeByIds(
                    $uuidsToDelete,
                    function () use ($onProgress, $total, &$deleted) {
                        $onProgress(++$deleted, $total);
                    }
                );
            }
        );
    }

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