<?php declare(strict_types=1);

namespace Newland\Toubiz\Sync\Neos\Command;

use Neos\Flow\Mvc\Exception\StopActionException;
use Newland\Toubiz\Api\ObjectAdapter\ArticleAdapterInterface;
use Newland\Toubiz\Api\ObjectAdapter\Concern\ExternalIdType;
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 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.
     */
    public function migrateArticlesToToubizNewCommand(bool $quiet = false): void
    {
        if ($quiet) {
            $this->output->setOutput(new NullOutput());
        }
        $this->throwIfMigrationPreconditionsAreNotMet($quiet);

        $this->outputLine('Searching for migratable articles');
        $pairs = iterator_to_array($this->findArticleMigrationPairs());

        if (!$quiet) {
            $this->askForConfirmationAndAbortIfNoneGiven(sprintf(
                'Found %d potential migratable articles. Proceed with migrating content references to them?',
                count($pairs)
            ));
        }

        foreach ($this->loopWithBar($pairs, 'Migrating') as [$before, $after]) {
            $this->updateNodeContents($before, $after);
            $this->addRedirect($before, $after);
        }
    }

    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 '
            );
        }

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

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

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

    private function findArticleMigrationPairs(): \Generator
    {
        $this->articleRepository->setLanguage(null);

        $articleByExternalIdQuery = $this->articleRepository
            ->createQueryBuilder('article')
            ->join('article.externalIds', 'externalId')
            ->where('article.sourceSystem = :system AND externalId.type = :type AND externalId.id = :id')
            ->select('article.Persistence_Object_Identifier')
            ->setMaxResults(1);

        // external id matches (same externalid on old & new found)
        foreach (ExternalIdType::values() as $type) {
            $legacyIdPairs = $this->articleRepository
                ->createQueryBuilder('article')
                ->join('article.externalIds', 'externalId')
                ->where('article.sourceSystem = :system AND externalId.type = :type')
                ->select('externalId.id', 'article.Persistence_Object_Identifier')
                ->getQuery()
                ->execute([
                    'type' => $type,
                    'system' => ArticleAdapterInterface::SOURCE_TOUBIZ_LEGACY,
                ], Query::HYDRATE_ARRAY);

            $message = sprintf('old externalId[type=%s] -> new externalId[type=%s]', $type, $type);
            foreach ($this->loopWithBar($legacyIdPairs, $message) as $row) {
                $newArticleId = (clone $articleByExternalIdQuery)->getQuery()->execute([
                        'system' => ArticleAdapterInterface::SOURCE_TOUBIZ,
                        'type' => $type,
                        'id' => $row['id']
                    ], Query::HYDRATE_ARRAY)[0]['Persistence_Object_Identifier'] ?? null;

                if ($newArticleId) {
                    yield [$row['Persistence_Object_Identifier'], $newArticleId];
                }
            }
        }

        // original id -> `toubiz_legacy` remote id
        $originalIdRows = $this->articleRepository
            ->createQueryBuilder('article')
            ->where('article.sourceSystem = :system')
            ->setParameter('system', ArticleAdapterInterface::SOURCE_TOUBIZ_LEGACY)
            ->select('article.originalId', 'article.Persistence_Object_Identifier')
            ->getQuery()
            ->execute([], Query::HYDRATE_ARRAY);

        $message = 'old originalId -> new externalId[type=toubiz_legacy]';
        foreach ($this->loopWithBar($originalIdRows, $message) as $row) {
            $article = (clone $articleByExternalIdQuery)->getQuery()->execute([
                    'system' => ArticleAdapterInterface::SOURCE_TOUBIZ,
                    'type' => ExternalIdType::TOUBIZ_LEGACY,
                    'id' => $row['originalId']
                ], Query::HYDRATE_ARRAY)[0]['Persistence_Object_Identifier'] ?? null;

            if ($article) {
                yield [$row['Persistence_Object_Identifier'], $article];
            }
        }

        $message = 'old originalId -> new externalId[type=toubiz_legacy_remote_id]';
        foreach ($this->loopWithBar($originalIdRows, $message) as $row) {
            $article = (clone $articleByExternalIdQuery)->getQuery()->execute([
                    'system' => ArticleAdapterInterface::SOURCE_TOUBIZ,
                    'type' => ExternalIdType::TOUBIZ_LEGACY_REMOTE_ID,
                    'id' => $row['originalId']
                ], Query::HYDRATE_ARRAY)[0]['Persistence_Object_Identifier'] ?? null;

            if ($article) {
                yield [$row['Persistence_Object_Identifier'], $article];
            }
        }

        // `toubiz` external id -> original id
        $externalIdRows = $this->articleRepository
            ->createQueryBuilder('article')
            ->leftJoin('article.externalIds', 'externalId')
            ->select('externalId.id', 'article.Persistence_Object_Identifier')
            ->where('externalId.type = :type')
            ->setParameter('type', ExternalIdType::TOUBIZ)
            ->getQuery()
            ->execute([], Query::HYDRATE_ARRAY);

        foreach ($this->loopWithBar($externalIdRows, 'old externalId[type=toubiz] -> new originalId') as $row) {
            $article = $this->articleRepository->createQueryBuilder('article')
                    ->select('article.Persistence_Object_Identifier')
                    ->where('article.sourceSystem = :system AND article.originalId = :id')
                    ->setMaxResults(1)
                    ->getQuery()
                    ->execute([
                        'system' => ArticleAdapterInterface::SOURCE_TOUBIZ,
                        'id' => $row['id']
                    ], Query::HYDRATE_ARRAY)[0]['Persistence_Object_Identifier'] ?? null;

            if ($article) {
                yield [$row['Persistence_Object_Identifier'], $article];
            }
        }
    }

    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();
        foreach ($data as $item) {
            yield $item;
            $progress->advance();
        }
        $progress->finish();
        $this->outputLine();
    }
}
