<?php declare(strict_types=1);

namespace Newland\Toubiz\Sync\Neos\Command;

use Neos\Flow\Core\Booting\Scripts;
use Neos\Flow\Mvc\Exception\StopActionException;
use Newland\Toubiz\Api\Constants\Language;
use Newland\Toubiz\Api\ObjectAdapter\ArticleAdapterInterface;
use Newland\Toubiz\Api\ObjectAdapter\Concern\ExternalIdType;
use Newland\Toubiz\Sync\Neos\Command\MigrationPair\CategoryPairs;
use Newland\Toubiz\Sync\Neos\Command\MigrationPair\ExternalIdToOriginalId;
use Newland\Toubiz\Sync\Neos\Command\MigrationPair\ExternalIdToExternalId;
use Newland\Toubiz\Sync\Neos\Command\MigrationPair\MigrationPairFinder;
use Newland\Toubiz\Sync\Neos\Command\MigrationPair\OriginalIdToExternalId;
use Newland\Toubiz\Sync\Neos\Domain\Repository\CategoryRepository;
use Newland\Toubiz\Sync\Neos\Service\UrlIdentifierRedirectService;
use Symfony\Component\Console\Helper\ProgressBar;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Neos\Flow\Persistence\Doctrine\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Neos\Flow\Annotations as Flow;
use Doctrine\ORM\EntityManagerInterface;
use Newland\Toubiz\Sync\Neos\Domain\Model\AbstractEntity;
use Newland\Toubiz\Sync\Neos\Domain\Model\Article;
use Newland\Toubiz\Sync\Neos\Domain\Model\Category;
use Newland\Toubiz\Sync\Neos\Domain\Model\Event;
use Newland\Toubiz\Sync\Neos\Domain\Model\EventDate;
use Newland\Toubiz\Sync\Neos\Domain\Model\Service;
use Newland\Toubiz\Sync\Neos\Domain\Repository\ArticleRepository;
use Newland\Toubiz\Sync\Neos\Orm\Uuid\CustomUuidGeneration;
use Newland\Toubiz\Sync\Neos\Utility\DatabaseUtility;
use Symfony\Component\Console\Output\NullOutput;

class IdentifierCommandController extends AbstractCommandController
{

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

    /**
     * @var CategoryRepository
     * @Flow\Inject()
     */
    protected $categoryRepository;

    /** @var array<string, string> */
    protected $types = [
        'article' => Article::class,
        'category' => Category::class,
        'event' => Event::class,
        'eventDate' => EventDate::class,
        'service' => Service::class,
    ];

    /**
     * @var EntityManagerInterface
     * @Flow\Inject(lazy=false)
     */
    protected $entityManager;

    /**
     * @var UrlIdentifierRedirectService
     * @Flow\Inject()
     */
    protected $urlIdentifierRedirect;

    /**
     * Regenerates the persistence object identifiers of entities that use deterministic
     * identifiers. Identifiers that are not yet deterministic will be changed and references
     * to the old identifier will be updated.
     *
     * The `--type` argument can be used in order to specify which entity type should be regenerated.
     * It must be one of the following: ['article', 'category', 'event', 'eventDate', 'service'].
     * If the argument is not specified then all will be regenerated.
     *
     * @param string|null $type Entity type to change.
     * @param bool $quiet Do not print status information to the console. Defaults to `false`.
     */
    public function regenerateCommand(string $type = null, bool $quiet = false): void
    {
        if ($quiet) {
            $this->output->setOutput(new NullOutput());
        }

        if ($type === null) {
            foreach ($this->types as $className) {
                $this->regenerateIdentifiersForClass($className);
            }
        } else {
            $this->regenerateIdentifiersForClass($this->types[ $type ]);
        }
    }

    /**
     * @return CustomUuidGeneration|AbstractEntity|null
     */
    private function getEntity(ClassMetadata $metadata, string $identifier)
    {
        $query = $this->entityManager->createQueryBuilder()
            ->select('entity')
            ->from($metadata->getName(), 'entity');

        $query->where($query->expr()->eq('entity.' . $metadata->getIdentifierFieldNames()[0], ':id'));
        $query->setMaxResults(1);

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

    private function regenerateIdentifiersForClass(string $className): void
    {
        /** @var ClassMetadata $metadata */
        $metadata = $this->entityManager->getMetadataFactory()->getMetadataFor($className);
        $this->output->outputLine("\n\n" . $className);

        $identifierRows = $this->entityManager->createQueryBuilder()
            ->select('entity.' . $metadata->getIdentifierFieldNames()[0] . ' as identifier')
            ->from($metadata->getName(), 'entity')
            ->getQuery()
            ->execute([], Query::HYDRATE_ARRAY);

        $this->output->progressStart(\count($identifierRows));

        $updated = 0;
        foreach ($identifierRows as $identifierRow) {
            $item = $this->getEntity($metadata, $identifierRow['identifier']);

            $before = $item->getPersistenceObjectIdentifier();
            $after = $item->generateUuid()->toString();

            if ($before !== $after) {
                $alreadyHasEntityWithNewIdentifier = $this->getEntity($metadata, $after) !== null;
                if ($alreadyHasEntityWithNewIdentifier) {
                    $this->persistenceManager->remove($item);
                } else {
                    $item->setPersistenceObjectIdentifier($after);
                    $this->persistenceManager->update($item);
                }

                DatabaseUtility::withoutForeignKeyChecks(
                    $this->entityManager,
                    function () use ($className, $before, $after) {
                        $this->updateAllRelations($className, $before, $after);
                        $this->persistenceManager->persistAll();
                    }
                );

                $this->updateNodeContents($before, $after);

                $this->persistenceManager->persistAll();
                $updated++;
            }

            $this->output->progressAdvance();
            $this->output(sprintf(' new identifiers: %d', $updated));
        }

        $this->output->progressFinish();
        $this->output(sprintf(' new identifiers: %d', $updated));
    }

    private function updateAllRelations(string $className, string $before, string $after): void
    {
        /** @var ClassMetadata $metadata */
        $metadata = $this->entityManager->getMetadataFactory()->getMetadataFor($className);
        foreach ($metadata->getAssociationMappings() as $associationName => $configuration) {
            [$tableName, $columnName] =
                $this->extractTableAndColumnFromAssociationMapping($metadata, $configuration);

            if ($tableName && $columnName) {
                $sql = sprintf(
                    'UPDATE %s SET %s="%s" WHERE %s="%s"',
                    $tableName,
                    $columnName,
                    $after,
                    $columnName,
                    $before
                );

                try {
                    $this->entityManager->getConnection()->exec($sql);
                } catch (UniqueConstraintViolationException $e) {
                    // This relationship already exists - the original record can be removed
                    $sql = sprintf('DELETE FROM %s WHERE %s="%s"', $tableName, $columnName, $before);
                    $this->entityManager->getConnection()->exec($sql);
                }
            }
        }
    }

    private function updateNodeContents(string $before, string $after): void
    {
        $sql = sprintf(
            'UPDATE %s SET properties=REPLACE(properties, "%s", "%s") WHERE properties LIKE "%s"',
            'neos_contentrepository_domain_model_nodedata',
            $before,
            $after,
            '%' . $before . '%'
        );

        $this->entityManager->getConnection()->exec($sql);
    }


    /**
     * @return (string|null)[]
     */
    private function extractTableAndColumnFromAssociationMapping(ClassMetadata $metadata, array $configuration)
    {
        $joinTable = $configuration['joinTable'] ?? null;
        $targetEntity = $configuration['targetEntity'] ?? null;
        $mappedBy = $configuration['mappedBy'] ?? null;
        $joinColumns = $configuration['joinColumns'] ?? null;

        if ($joinTable) {
            return [
                $joinTable['name'] ?? null,
                $joinTable['joinColumns'][0]['name'] ?? null,
            ];
        }

        if ($joinColumns) {
            return [
                $metadata->getTableName(),
                $joinColumns[0]['name'] ?? null,
            ];
        }

        if ($targetEntity && $mappedBy) {
            /** @var ClassMetadata $foreignMetadata */
            $foreignMetadata = $this->entityManager->getMetadataFactory()->getMetadataFor($targetEntity);
            $foreignConfiguration = $foreignMetadata->getAssociationMapping($mappedBy);

            return [
                $foreignMetadata->getTableName(),
                $foreignConfiguration['joinColumns'][0]['name'] ?? null,
            ];
        }

        return [null, null];
    }


    /**
     * Migrates toubiz legacy articles to toubiz new.
     * In order for this migration to succeed the following conditions must be met:
     *
     * - Both, toubiz old and toubiz new data must exist in the database
     * - A mapping from toubiz old to toubiz new must exist either from importing from toubiz old or from importing
     *   from toubiz new.
     *
     * @param bool $quiet Specify, if you want to skip all interactive questions and disable all command line output.
     *      This is highly discouraged, as you can very easily loose data if you don't pay attention.
     * @param bool $skipCacheClearing Disables clearing the caches after migrating the data.
     */
    public function migrateArticlesToToubizNewCommand(bool $quiet = false, bool $skipCacheClearing = false): void
    {
        if ($quiet) {
            $this->output->setOutput(new NullOutput());
        }
        $this->throwIfMigrationPreconditionsAreNotMet($quiet);

        $this->outputLine('Searching for migratable articles & categories');
        $articlePairs = $this->findArticleMigrationPairs();
        $categoryPairs = $this->findCategoryMigrationPairs(
            'resource://Newland.Toubiz.Sync.Neos/Private/Mapping/Categories/ToToubizNew.json'
        );

        if (!$quiet) {
            $this->confirm(sprintf(
                'Found %d potential migratable articles and %d potentially migratable categories. '
                . ' Proceed with migrating content references to them?',
                count($articlePairs),
                count($categoryPairs)
            ));
        }

        foreach ($this->loopWithBar($categoryPairs, 'Migrating categories') as $before => $after) {
            $this->updateNodeContents($before, $after);
        }

        foreach ($this->loopWithBar($articlePairs, 'Migrating articles') as $before => $after) {
            $this->updateNodeContents($before, $after);
            $this->addRedirect($before, $after);
        }

        if (!$skipCacheClearing) {
            $this->outputLine('Flushing cache');
            $this->objectPathMappingService->flushMappings(Article::class);
            Scripts::executeCommand('neos.flow:cache:flush', $this->flowSettings);
            $this->outputLine();
            $this->outputLine('Warming up cache');
            Scripts::executeCommand('neos.flow:cache:warmup', $this->flowSettings);
            $this->outputLine();
        }
    }

    private function throwIfMigrationPreconditionsAreNotMet(bool $quiet): void
    {

        $toubizLegacyCount = $this->articleRepository
            ->count([ 'sourceSystem' => ArticleAdapterInterface::SOURCE_TOUBIZ_LEGACY ]);
        if ($toubizLegacyCount === 0) {
            throw new StopActionException(
                'You don\'t seem to have any articles from toubiz old in your database.'
                . ' Please ensure, that you still have the old articles in your database before calling this command.'
                . ' If you are unsure about the order of operations in order to migrate data, please refer to the'
                . ' "actions required" section of the 1.20 changelog.'
            );
        }

        $toubizNewCount = $this->articleRepository
            ->count([ 'sourceSystem' => ArticleAdapterInterface::SOURCE_TOUBIZ ]);
        if ($toubizNewCount === 0) {
            throw new StopActionException(
                'You don\'t seem to have any articles from toubiz new in your database.'
                . ' Please ensure, that you have new articles in your database before calling this command.'
                . ' If you are unsure about the order of operations in order to migrate data, please refer to the'
                . ' "actions required" section of the 1.20 changelog.'
            );
        }

        if (!$quiet) {
            $this->confirm(
                'Did you setup & sync data from the new source?',
                false
            );
            $this->confirm(
                'Have you created a database backup before running this migration?',
                false
            );
        }
    }

    private function addRedirect(string $fromUuid, string $toUuid): void
    {
        $from = $this->articleUrlIdentifier($fromUuid);
        $to = $this->articleUrlIdentifier($toUuid);

        if ($from && $to) {
            $this->urlIdentifierRedirect->addRedirect($from, $to);
        }
    }

    private function articleUrlIdentifier(string $uuid): ?string
    {
        $this->articleRepository->setLanguage(null);
        return $this->articleRepository->createQueryBuilder('article')
            ->select('article.urlIdentifier')
            ->where('article.Persistence_Object_Identifier = :uuid')
            ->setMaxResults(1)
            ->getQuery()
            ->execute([ 'uuid' => $uuid ], Query::HYDRATE_ARRAY)[0]['urlIdentifier'] ?? null;
    }

    private function findArticleMigrationPairs(): array
    {
        /** @var MigrationPairFinder[] $finders */
        $finders = [
            new OriginalIdToExternalId(
                $this->output->getOutput(),
                ArticleAdapterInterface::SOURCE_TOUBIZ_LEGACY,
                ArticleAdapterInterface::SOURCE_TOUBIZ,
                ExternalIdType::TOUBIZ_LEGACY
            ),
            new OriginalIdToExternalId(
                $this->output->getOutput(),
                ArticleAdapterInterface::SOURCE_TOUBIZ_LEGACY,
                ArticleAdapterInterface::SOURCE_TOUBIZ,
                ExternalIdType::TOUBIZ_LEGACY_REMOTE_ID
            ),
            new ExternalIdToOriginalId(
                $this->output->getOutput(),
                ArticleAdapterInterface::SOURCE_TOUBIZ,
                ExternalIdType::TOUBIZ,
                ArticleAdapterInterface::SOURCE_TOUBIZ
            )
        ];
        foreach (ExternalIdType::values() as $type) {
            $finders[] = new ExternalIdToExternalId(
                $this->output->getOutput(),
                ArticleAdapterInterface::SOURCE_TOUBIZ_LEGACY,
                $type,
                ArticleAdapterInterface::SOURCE_TOUBIZ,
                $type
            );
        }

        $pairs = [];
        foreach (Language::values() as $language) {
            foreach ($finders as $finder) {
                foreach ($finder->findMigrationPairs($language, $pairs) as $before => $after) {
                    $pairs[$before] = $after;
                }
            }
        }

        return $pairs;
    }

    private function findCategoryMigrationPairs(string $path): array
    {
        $finder = new CategoryPairs($this->output->getOutput(), $path);
        $pairs = [ ];

        foreach (Language::values() as $language) {
            foreach ($finder->findMigrationPairs($language, []) as $oldUid => $newUid) {
                $pairs[$oldUid] = $newUid;
            }
        }

        return $pairs;
    }

    private function loopWithBar(array $data, string $message = ''): \Generator
    {
        $progress = new ProgressBar($this->output->getOutput(), count($data));
        $progress->setFormat(' %current:5s%/%max:5s% [%bar%] %percent:3s%% | ' . $message);
        $progress->start();

        $i = 0;
        foreach ($data as $key => $item) {
            yield $key => $item;
            $progress->advance();

            if (++$i % 100 === 0) {
                $this->persistenceManager->persistAll();
                $this->persistenceManager->clearState();
                $this->validatorResolver->reset();
            }
        }
        $progress->finish();
        $this->outputLine();
    }
}
