<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CoreBundle\Fixtures\Doctrine;

use Contao\CoreBundle\Doctrine\DBAL\Types\BinaryStringType;
use Doctrine\Bundle\DoctrineBundle\Mapping\ClassMetadataFactory;
use Doctrine\Bundle\DoctrineBundle\Mapping\MappingDriver;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Events;
use Doctrine\ORM\ORMSetup;
use Doctrine\ORM\Tools\ResolveTargetEntityListener;
use Doctrine\ORM\Tools\SchemaTool;
use Ferienpass\CoreBundle\Entity\Offer;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Participant;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Psr\Container\ContainerInterface;
use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator;
use Symfony\Bridge\Doctrine\Types\UlidType;
use Symfony\Bridge\Doctrine\Types\UuidType;

/**
 * A real EntityManager over the core bundle's entities, backed by an in-memory SQLite
 * database. Reproduces the pieces of the application's Doctrine wiring that the
 * mappings depend on: the custom DBAL types, the `doctrine.uuid_generator` service
 * that three entities reference by id, and the OfferInterface target resolution.
 *
 * SQLite means no MySQL-only functions, so the two the entity queries reach for are
 * registered as user functions on the connection: the generated SQL stays the SQL that
 * MySQL would run.
 */
final class OrmHarness
{
    private function __construct(public readonly EntityManagerInterface $em)
    {
    }

    /**
     * Builds the EntityManager and creates the schema for every mapped entity.
     */
    public static function boot(): self
    {
        self::registerTypes();

        $config = ORMSetup::createAttributeMetadataConfiguration(
            paths: [\dirname(__DIR__, 4).'/src/Entity'],
            isDevMode: true,
        );

        // Three entities declare #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')],
        // which is a service id rather than a class name. DoctrineBundle resolves those
        // through a locator; without it the metadata factory cannot build them at all.
        $config->setMetadataDriverImpl(new MappingDriver($config->getMetadataDriverImpl(), self::idGeneratorLocator()));
        $config->setClassMetadataFactoryName(ClassMetadataFactory::class);

        $config->addCustomStringFunction('JSON_CONTAINS_PATH', JsonContainsPath::class);

        $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $config);
        self::registerSqliteFunctions($connection);

        $em = new EntityManager($connection, $config);

        $resolveTargetEntity = new ResolveTargetEntityListener();
        $resolveTargetEntity->addResolveTargetEntity(OfferInterface::class, Offer::class, []);
        $resolveTargetEntity->addResolveTargetEntity(ParticipantInterface::class, Participant::class, []);
        $em->getEventManager()->addEventListener(Events::loadClassMetadata, $resolveTargetEntity);
        $em->getEventManager()->addEventListener(Events::onClassMetadataNotFound, $resolveTargetEntity);

        self::createSchema($em);

        return new self($em);
    }

    private static function createSchema(EntityManagerInterface $em): void
    {
        $schema = new SchemaTool($em)->getSchemaFromMetadata($em->getMetadataFactory()->getAllMetadata());

        // MySQL scopes index names to their table, SQLite scopes them to the database.
        // Three entities index a column as plain `id`, which collides here; qualify
        // every index name so the schema can be created at all.
        foreach ($schema->getTables() as $table) {
            foreach ($table->getIndexes() as $index) {
                if (!$index->isPrimary()) {
                    $table->renameIndex($index->getName(), $table->getName().'_'.$index->getName());
                }
            }
        }

        $connection = $em->getConnection();
        foreach ($schema->toSql($connection->getDatabasePlatform()) as $sql) {
            $connection->executeStatement($sql);
        }
    }

    private static function registerTypes(): void
    {
        $types = [
            'uuid' => UuidType::class,
            'ulid' => UlidType::class,
            // Aliases the application registers (see its doctrine.yaml / the core
            // bundle's prepended DBAL config).
            'id' => UuidType::class,
            'binary_string' => BinaryStringType::class,
        ];

        foreach ($types as $name => $class) {
            if (!Type::hasType($name)) {
                Type::addType($name, $class);
            }
        }
    }

    private static function idGeneratorLocator(): ContainerInterface
    {
        return new class implements ContainerInterface {
            public function has(string $id): bool
            {
                return 'doctrine.uuid_generator' === $id;
            }

            public function get(string $id): UuidGenerator
            {
                if (!$this->has($id)) {
                    throw new \InvalidArgumentException(\sprintf('Unknown id generator "%s".', $id));
                }

                return new UuidGenerator();
            }
        };
    }

    /**
     * SQLite equivalents for the MySQL functions the entity queries emit.
     */
    private static function registerSqliteFunctions(Connection $connection): void
    {
        $pdo = $connection->getNativeConnection();
        \assert($pdo instanceof \PDO);

        // JSON_CONTAINS_PATH(doc, 'one'|'all', path, …) — the paths in use are all of
        // the plain `$.key` form, which is all this needs to understand.
        self::createFunction($pdo, 'JSON_CONTAINS_PATH', static function (?string $document, ?string $mode, string ...$paths): ?int {
            if (null === $document || null === $mode) {
                return null;
            }

            $decoded = json_decode($document, true);
            if (!\is_array($decoded)) {
                return 0;
            }

            $found = array_map(
                static fn (string $path): bool => \array_key_exists(ltrim($path, '$.'), $decoded),
                $paths,
            );

            return (int) ('all' === strtolower($mode) ? !\in_array(false, $found, true) : \in_array(true, $found, true));
        });
    }

    private static function createFunction(\PDO $pdo, string $name, callable $callback): void
    {
        // PDO::sqliteCreateFunction() is deprecated as of PHP 8.5 in favour of the
        // Pdo\Sqlite subclass, which doctrine/dbal does not hand out yet.
        if ($pdo instanceof \Pdo\Sqlite) {
            $pdo->createFunction($name, $callback);

            return;
        }

        $pdo->sqliteCreateFunction($name, $callback);
    }
}
