<?php declare(strict_types=1);

namespace Newland\Toubiz\Sync\Neos\Command;

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\Orm\Uuid\CustomUuidGeneration;
use Newland\Toubiz\Sync\Neos\Utility\DatabaseUtility;
use Symfony\Component\Console\Output\NullOutput;

class IdentifierCommandController extends AbstractCommandController
{

    /** @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;

    /**
     * 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 ];
    }
}
