<?php
namespace Newland\Toubiz\Sync\Neos\Command;

/*
 * 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\AbstractQuery;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\Core\Booting\Scripts;
use Newland\Toubiz\Api\ObjectAdapter\Concern\ArticleConstants;
use Newland\Toubiz\Sync\Neos\Command\Helper\ConfigurationHelper;
use Newland\Toubiz\Sync\Neos\Command\Task\CallsImportTask;
use Newland\Toubiz\Api\Utility\ArrayUtility;
use Newland\Toubiz\Api\Utility\TimeDelta;
use Newland\Toubiz\Sync\Neos\Command\Task\ArticleSynchronizationTask;
use Newland\Toubiz\Sync\Neos\Command\Task\SyncArticlesFromToubizApiV1;
use Newland\Toubiz\Sync\Neos\Command\Task\SynchronizeCitiesFromLegacyToubiz;
use Newland\Toubiz\Sync\Neos\Command\Task\SynchronizeCongressLocationsFromTportal;
use Newland\Toubiz\Sync\Neos\Command\Task\SynchronizationTask;
use Newland\Toubiz\Sync\Neos\Command\Task\SynchronizeDirectMarketerFromLegacyToubiz;
use Newland\Toubiz\Sync\Neos\Command\Task\SynchronizeGastronomyFromLegacyToubiz;
use Newland\Toubiz\Sync\Neos\Command\Task\SynchronizeLodgingsFromTportal;
use Newland\Toubiz\Sync\Neos\Command\Task\SynchronizePointOfInterestsFromDbService;
use Newland\Toubiz\Sync\Neos\Command\Task\SynchronizeToursFromLegacyToubiz;
use Newland\Toubiz\Sync\Neos\Command\Task\SynchronizeToursFromOutdoorActive;
use Newland\Toubiz\Sync\Neos\Domain\Model\Article;
use Newland\Toubiz\Sync\Neos\Domain\Repository\ArticleRepository;
use Newland\Toubiz\Sync\Neos\Enum\ArticleType;
use Newland\Toubiz\Sync\Neos\Domain\Repository\GeometryRepository;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Output\NullOutput;

/**
 * Articles command controller.
 *
 * Provides commands to manipulate article data.
 *
 * @Flow\Scope("singleton")
 */
class ArticlesCommandController extends AbstractCommandController
{
    use CallsImportTask;
    const TYPE_FLUSH = 'ARTICLES';

    /** @var SynchronizationTask[][] */
    protected $tasks;

    public function __construct()
    {
        parent::__construct();

        $this->tasks = [
            ArticleType::CITIES => [
                new SyncArticlesFromToubizApiV1(ArticleType::CITIES),
                new SynchronizeCitiesFromLegacyToubiz(),
            ],
            ArticleType::POI => [
                new SyncArticlesFromToubizApiV1(ArticleType::POI),
                new SynchronizePointOfInterestsFromDbService(),
            ],
            ArticleType::GASTRONOMY => [
                new SyncArticlesFromToubizApiV1(ArticleType::GASTRONOMY),
                new SynchronizeGastronomyFromLegacyToubiz(),
            ],
            ArticleType::TOURS => [
                new SyncArticlesFromToubizApiV1(ArticleType::TOURS),
                new SynchronizeToursFromLegacyToubiz(),
                new SynchronizeToursFromOutdoorActive(),
            ],
            ArticleType::LODGINGS => [
                new SyncArticlesFromToubizApiV1(ArticleType::LODGINGS),
                new SynchronizeLodgingsFromTportal()
            ],
            ArticleType::DIRECT_MARKETERS => [
                new SyncArticlesFromToubizApiV1(ArticleType::DIRECT_MARKETERS),
                new SynchronizeDirectMarketerFromLegacyToubiz(),
            ],
            ArticleType::CONGRESS_LOCATIONS => [
                new SynchronizeCongressLocationsFromTportal(),
            ],
        ];
    }


    /**
     * @var ArticleRepository
     * @Flow\Inject()
     */
    protected $articleRepository;

    /**
     * @var GeometryRepository
     * @Flow\Inject()
     */
    protected $geometryRepository;

    /**
     * @var ConfigurationHelper
     * @Flow\Inject(lazy=false)
     */
    protected $configurationHelper;

    /**
     * Synchronize command.
     *
     * Updates local articles database from API data source.
     *
     * @param bool $importGeometryIfMissing
     * @param bool $finishHooks
     * @param bool $quiet
     * @param string $only Can be one article type of [poi, gastronomy, lodgings, tours, directMarketers]
     * @param int $minutesToLive The age of records in minutes after which they are considered "old" and will be
     *     deleted. Default is 7 days (=10080 minutes) ago.
     * @param string $onlyModifiedInLast Only synchronize articles that have been modified in the given timeframe.
     *     Examples for valid timeframes: '2 days', '4d', '17h'.
     * @param int $concurrency Number of concurrent requests to execute.
     * @return void
     */
    public function synchronizeCommand(
        bool $importGeometryIfMissing = true,
        bool $finishHooks = true,
        bool $quiet = false,
        string $only = '',
        int $minutesToLive = 10080,
        string $onlyModifiedInLast = null,
        int $concurrency = 10
    ): void {
        if (!$quiet) {
            $this->showProgressOnCommandLine();
        } else {
            $this->output->setOutput(new NullOutput());
        }

        if ($importGeometryIfMissing) {
            $this->importGeometryIfNotAlreadyImported();
        }

        $articleTypes = ArticleType::values();
        if ($only !== '') {
            $articleTypes = ArrayUtility::trimExplode(',', $only);
        }

        $this->configurationHelper->setDefaults(
            [
                'delta' => $onlyModifiedInLast ? TimeDelta::create($onlyModifiedInLast) : null,
                'concurrency' => $concurrency,
            ]
        );

        /** @var string[] $touchedIds */
        $touchedIds = [];
        foreach ($articleTypes as $currentArticleType) {
            $touchedIds = array_merge($touchedIds, $this->synchronizeArticleType($currentArticleType));
            $mainType = array_flip(ArticleType::$map)[$currentArticleType];

            if ($onlyModifiedInLast === null) {
                $this->deleteOldRecords($minutesToLive, $mainType);
            }
        }

        if ($finishHooks) {
            $this->flushObjectPathMappings($touchedIds);
            $this->emitFinish();
        }
    }

    /**
     * Removes articles from the system according to the given clause.
     * If no WHERE clause is given then all articles will be deleted.
     *
     * # Remove all articles
     * $ php flow articles:remove
     *
     * # Remove articles according to WHERE clause
     * $ php flow articles:remove --where='article.client="westlicherbodensee"'
     *
     * # Remove single article
     * $ php flow articles:remove \
     *      --where="article.Persistence_Object_Identifier='a4625eb4-6e84-4834-969d-c9f1d447408b'"
     *
     *
     * @param string|null $where DQL WHERE clause selecting the articles to delete.
     * @param string|null $language Language to delete articles in. Deletes in all languages if not specified.
     */
    public function removeCommand(string $where = null, string $language = null): void
    {
        if ($where) {
            $this->outputLine('Deleting articles WHERE ' . $where);
        } else {
            $this->outputLine('Deleting all articles');
        }

        $this->showProgressOnCommandLine();
        if ($language === null) {
            $this->articleRepository->withoutLanguageHandling(
                function () use ($where) {
                    $this->remove($where, null);
                }
            );
        } else {
            $this->remove($where, $language);
        }
    }

    public function remove(?string $where, ?string $language): void
    {
        $this->articleRepository->setLanguage($language);
        $query = $this->articleRepository->createQueryBuilder('article');
        if ($where) {
            $query->where($where);
        }
        $count = (clone $query)->select('COUNT(article) AS count')
                ->getQuery()
                ->execute([], AbstractQuery::HYDRATE_ARRAY)[0]['count'] ?? 0;
        $this->askForConfirmationAndAbortIfNoneGiven(sprintf('Do you really want to remove %d articles?', $count));
        $this->emitStart(sprintf('Remove %d articles', $count), compact('language'));
        $this->output->progressStart($count);

        $processed = 0;
        foreach ($query->getQuery()->iterate() as $articles) {
            foreach ($articles as $article) {
                $this->articleRepository->remove($article);
                $this->emitProgress(sprintf('Remove %d articles', $count), ++$processed, compact('language'));
                if ($processed % 250 === 0) {
                    $this->persistenceManager->persistAll();
                    $this->persistenceManager->clearState();
                    $this->validatorResolver->reset();
                }
            }
        }

        $this->emitEnd(sprintf('Remove %d articles', $count), compact('language'));
    }

    public function setDefaultClientCommand(string $only = null): void
    {
        $tasksPerType = $this->tasks;

        if ($only) {
            ArticleType::throwIfInvalid($only);
            $tasksPerType = [
                $only => $this->tasks[$only],
            ];
        }

        foreach ($tasksPerType as $articleTypeName => $tasks) {
            $articleType = array_flip(ArticleType::$map)[$articleTypeName];

            /** @var ArticleSynchronizationTask $task */
            foreach ($tasks as $task) {
                if ($task->isConfigured($this->configurationHelper) === false) {
                    $this->output->outputLine(
                        'No configuration found for task %s. Skipping.',
                        [
                            get_class($task),
                        ]
                    );
                    continue;
                }

                $result = $task->setDefaultClient($this->configurationHelper, 'article.mainType = ' . $articleType);
                $this->output->outputLine(
                    'Updated %d articles with mainType %d to have the default client "%s".',
                    [
                        $result->getRowsUpdated(),
                        $articleType,
                        $result->getClient(),
                    ]
                );
            }
        }

        $this->output->outputLine('If clients have changed, please run <info>php flow identifier:regenerate</info>.');
    }


    private function synchronizeArticleType(string $type): array
    {
        ArticleType::throwIfInvalid($type);

        $ids = [];
        foreach ($this->tasks[$type] ?? [] as $task) {
            $this->logger->info('Starting article synchronization task ' . $task->name());
            $ids = array_merge($ids, $this->callSynchronizationTask($task, $this->output));
        }
        return $ids;
    }

    private function deleteOldRecords(int $minutesToLive, int $mainType = null): void
    {
        $this->articleRepository->withoutLanguageHandling(function () use ($minutesToLive, $mainType) {
            $timeToLive = new \DateInterval(sprintf('PT%dM', $minutesToLive));

            $query = $this->articleRepository->queryForOldRecords($timeToLive, $mainType);
            $count = (clone $query)
                    ->select('COUNT(article) as count')
                    ->getQuery()
                    ->execute(null, Query::HYDRATE_ARRAY)[0]['count'] ?? 0;
            $ids = (clone $query)
                ->select('article.Persistence_Object_Identifier as id')
                ->getQuery()
                ->execute(null, Query::HYDRATE_ARRAY);

            $this->outputLine();
            $progress = new ProgressBar($this->output->getOutput());
            $progress->setFormat(
                '%current% / %max% [%bar%] Deleting records older than '
                . $this->minutesToHumanReadable($minutesToLive)
            );
            $progress->start();
            $progress->setMaxSteps($count);

            foreach ($ids as $i => $row) {
                $article = $this->articleRepository->findByIdentifier($row['id']);
                if ($article !== null) {
                    $this->articleRepository->remove($article);
                }
                $progress->advance();

                if ($i % 50 === 0) {
                    $this->persistenceManager->persistAll();
                }
            }

            $this->entityManager->flush();
            $progress->finish();
        });
    }

    private function minutesToHumanReadable(int $minutes): string
    {
        if ($minutes === 0) {
            return sprintf('%dM', $minutes);
        }

        $hours = floor($minutes / 60);
        $days = floor($hours / 24);
        $minutes -= $hours * 60;
        $hours -= $days * 24;

        $formatted = [];
        if ($days > 0) {
            $formatted[] = $days . ' days';
        }
        if ($hours > 0) {
            $formatted[] = $hours . ' hours';
        }
        if ($minutes > 0) {
            $formatted[] = $minutes . ' minutes';
        }

        return implode(' ', $formatted);
    }

    private function importGeometryIfNotAlreadyImported(): void
    {
        if ($this->geometryRepository->isEmpty()) {
            $this->outputLine('Geometries not found, running geometry:seedzippolygons command');
            Scripts::executeCommand('newland.toubiz.sync.neos:geometry:seedzippolygons', $this->flowSettings);

            $this->outputLine("\nGeometries Imported, Continuing...");
        } else {
            $this->outputLine("\nGeometries Found, Continuing...");
        }
    }

    /**
     * @param string[]|null $touchedIds
     */
    private function flushObjectPathMappings(array $touchedIds = null): void
    {
        $this->objectPathMappingService->flushMappings(Article::class, $touchedIds);
        $this->emitFlush(static::TYPE_FLUSH, $touchedIds);
    }
}
