<?php

declare(strict_types=1);

namespace App\Service\Doctrine\SchemaValidation;

use ApiPlatform\Metadata\ApiResource;
use App\Service\Utils\EntityUtils;
use App\Service\Utils\FileUtils;
use App\Service\Utils\PathUtils;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\InverseJoinColumn;
use Doctrine\ORM\Mapping\JoinColumn;
use Doctrine\ORM\Mapping\JoinTable;
use Doctrine\ORM\Mapping\ManyToMany;
use Doctrine\ORM\Mapping\ManyToOne;
use Doctrine\ORM\Mapping\OneToMany;
use Doctrine\ORM\Mapping\OneToOne;
use Nette\InvalidStateException;
use Nette\PhpGenerator\Attribute;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\Method;
use Nette\PhpGenerator\Parameter;
use Nette\PhpGenerator\PhpFile;
use Nette\PhpGenerator\PhpNamespace;
use Nette\PhpGenerator\Property;
use Nette\PhpGenerator\PsrPrinter;

/**
 * Service produisant les snippets des entités, propriétés et méthodes
 * manquantes dans les entités de la V2.
 *
 * Les résultats sont enregistrés dans le répertoire `~/schema_validation_snippets`.
 *
 * @see https://github.com/nette/php-generator
 */
class SchemaSnippetsMaker
{
    public function __construct(
        private readonly EntityUtils $entityUtils,
    ) {
    }

    /**
     * Make entities snippets from a 'diff' array,
     * as generated by SchemaValidationService::validateSchema().
     *
     * @param array<string, Difference | array<Difference>> $diff
     */
    public function makeSnippets(array $diff): void
    {
        $snippetsDir = $this->getSnippetsDir();
        $this->prepareSnippetsDir($snippetsDir);

        foreach ($diff as $entity => $differences) {
            if (empty($differences)) {
                continue;
            }

            $class = $this->makeSnippetEntityClass($entity);

            $methods = [];
            $collections = [];

            $expectedFields = [];
            if (!is_array($differences)) {
                // New entity
                foreach ($differences->getExpectedType() as $field => $type) {
                    $expectedFields[$field] = $type;
                }
            } else {
                // Existing entity
                foreach ($differences as $field => $difference) {
                    $expectedFields[$field] = $difference->getExpectedType();
                }
            }

            foreach ($expectedFields as $field => $expectedType) {
                $prop = is_array($expectedType) ?
                    $this->makeSnippetEntityCollectionProp($field, $expectedType) :
                    $this->makeSnippetEntitySimpleProp($field, $expectedType);

                $class->addMember($prop);

                if ($prop->getType() === Collection::class) {
                    $collections[] = $prop;
                }

                $methods = [...$methods, ...$this->makeMethodsSnippetForProp($prop)];
            }

            if ($collections) {
                $class->addMember(
                    $this->makeSnippetConstructor($collections)
                );
            }

            foreach ($methods as $method) {
                $class->addMember($method);
            }

            $file = $this->makeFileSnippet();

            $namespace = $this->makeNamespaceSnippet($entity);

            if (is_array($differences)) {
                foreach ($this->getEntityToImportFromRelations($differences) as $use) {
                    $namespace->addUse($use);
                }
            }

            try {
                $namespace->add($class);
            } catch (InvalidStateException $e) {
                var_dump($e->getMessage());
                continue;
            }

            $file->addNamespace($namespace);

            $fileName = $this->getSnippetPath($snippetsDir, $entity);

            $this->writeSnippet($file, $fileName);
        }
    }

    /**
     * Make the getters / setters for the given property.
     *
     * @return array<Method>
     */
    protected function makeMethodsSnippetForProp(Property $prop): array
    {
        $methods = [];

        if ($prop->getType() !== Collection::class) {
            $methods[] = $this->makeSnippetGetterForProp($prop);
            $methods[] = $this->makeSnippetSetterForProp($prop);
        } else {
            $methods[] = $this->makeSnippetGetterForProp($prop);
            $methods[] = $this->makeSnippetAdderForCollection($prop);
            $methods[] = $this->makeSnippetRemoverForCollection($prop);
        }

        return $methods;
    }

    /**
     * Génère un objet ClassType.
     *
     * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/ClassType.html
     */
    protected function makeSnippetEntityClass(string $entity): ClassType
    {
        $class = new ClassType($entity);

        $class->setAttributes([
            new Attribute(ApiResource::class, ['operations' => []]),
            new Attribute(Entity::class, []),
        ]);

        return $class;
    }

    /**
     * Retourne le chemin absolu vers le répertoire dans lesquels sont créés les snippets.
     */
    protected function getSnippetsDir(): string
    {
        return PathUtils::join(PathUtils::getProjectDir(), 'schema_validation_snippets');
    }

    /**
     * Vide et ajuste les droits du répertoire des snippets.
     */
    protected function prepareSnippetsDir(string $snippetsDir): void
    {
        if (is_dir($snippetsDir)) {
            FileUtils::rrmDir($snippetsDir);
        }
        mkdir($snippetsDir, 0777, true);
    }

    /**
     * Retourne le chemin absolu du snippet donné.
     */
    protected function getSnippetPath(string $snippetsDir, string $entity): string
    {
        try {
            $fullName = $this->entityUtils->getFullNameFromEntityName($entity);
        } catch (\LogicException) {
            $fullName = '_NameSpaceNotFound/'.$entity;
        }

        $relativePath = str_replace('\\', '/', $fullName).'.php';

        return PathUtils::join($snippetsDir, $relativePath);
    }

    /**
     * Créé le fichier du snippet sur le disque.
     */
    protected function writeSnippet(PhpFile $phpFile, string $fileName): void
    {
        if (!is_dir(dirname($fileName))) {
            mkdir(dirname($fileName), 0777, true);
        }

        $printer = new PsrPrinter();

        $content = $printer->printFile($phpFile);
        $content = $this->postProcessFileContent($content);

        $f = fopen($fileName, 'w+');
        try {
            fwrite($f, $content);
        } finally {
            fclose($f);
        }
    }

    /**
     * Parcourt les propriétés de type relations OneToOne ou ManyToOne pour lister
     * les classes d'entités à importer (pour le typage de ces propriétés).
     *
     * @param array<string, Difference> $differences
     *
     * @return array<string>
     */
    protected function getEntityToImportFromRelations(array $differences): array
    {
        $imports = [];

        foreach ($differences as $field => $difference) {
            if (
                !is_array($difference->getExpectedType())
                || !in_array(isset($difference->getExpectedType()['type']), [ClassMetadata::ONE_TO_ONE, ClassMetadata::MANY_TO_ONE])
                || !isset($difference->getExpectedType()['targetEntity'])
            ) {
                continue;
            }

            $entityName = $this->entityUtils->getEntityNameFromFullName($difference->getExpectedType()['targetEntity']);
            if (!$entityName) {
                continue;
            }

            try {
                $fullName = $this->entityUtils->getFullNameFromEntityName($entityName);
            } catch (\LogicException) {
                continue;
            }

            $imports[] = $fullName;
        }

        return $imports;
    }

    /**
     * Obtient le namespace pour l'entité donnée.
     */
    protected function getNamespaceValue(string $entity): string
    {
        try {
            $fullQualifiedName = str_contains($entity, '\\') ?
                $entity :
                $this->entityUtils->getFullNameFromEntityName($entity);

            return $this->entityUtils->getNamespaceFromName($fullQualifiedName);
        } catch (\LogicException) {
            return 'App\\Entity';
        }
    }

    /**
     * @param array<string, string|array<string>> $type
     */
    protected function getRelationTargetEntityName(array $type): string
    {
        $targetEntityName = $this->entityUtils->getEntityNameFromFullName($type['targetEntity']);
        try {
            return $this->entityUtils->getFullNameFromEntityName($targetEntityName);
        } catch (\LogicException) {
            return 'mixed';
        }
    }

    /**
     * Construit l'objet PhpFile.
     *
     * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/PhpFile.html
     */
    protected function makeFileSnippet(): PhpFile
    {
        $file = new PhpFile();
        $file->addComment('This file is auto-generated.');
        $file->setStrictTypes();

        return $file;
    }

    /**
     * Construit l'objet PhpNamespace.
     *
     * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/PhpNamespace.html
     */
    protected function makeNamespaceSnippet(string $entity): PhpNamespace
    {
        $namespaceValue = $this->getNamespaceValue($entity);

        $namespace = new PhpNamespace($namespaceValue);
        $namespace->addUse(ApiResource::class);
        $namespace->addUse('Doctrine\Common\Collections\ArrayCollection');
        $namespace->addUse('Doctrine\Common\Collections\Collection');
        $namespace->addUse('Doctrine\ORM\Mapping', 'ORM');

        return $namespace;
    }

    /**
     * Construit l'objet Property pour le champs 'id' d'une entité.
     *
     * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/Property.html
     */
    protected function makeIdPropertySnippet(): Property
    {
        $prop = new Property('id');
        $prop->setPrivate();
        $prop->setType('int');
        $prop->addAttribute('ORM\Id');
        $prop->addAttribute('ORM\Column');
        $prop->addAttribute('ORM\GeneratedValue');

        return $prop;
    }

    protected function getPhpTypeFromDoctrineType(string $doctrineType): string
    {
        return [
            'text' => 'string',
            'boolean' => 'bool',
            'integer' => 'int',
            'datetime' => '?\DateTimeInterface',
            'json_array' => 'array',
        ][$doctrineType] ?? 'mixed';
    }

    /**
     * Make a Property object for a simple field (not a relation).
     *
     * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/Property.html
     */
    protected function makeSnippetEntitySimpleProp(string $name, string $type): Property
    {
        if ($name === 'id') {
            return $this->makeIdPropertySnippet();
        }

        $php_type = $this->getPhpTypeFromDoctrineType($type);

        $prop = new Property($name);
        $prop->setProtected();
        $prop->setType($php_type);
        $prop->setComment('-- Warning : auto-generated property, checkup the attribute options --');

        if ($type === 'text') {
            $prop->addAttribute(Column::class, ['length' => 255, 'options' => ['nullable' => true]]);
        } elseif ($type === 'integer') {
            $prop->addAttribute(Column::class, ['type' => 'integer', 'options' => ['nullable' => true]]);
        } elseif ($type === 'boolean') {
            $prop->addAttribute(Column::class, ['options' => ['default' => false]]);
        } elseif ($type === 'datetime') {
            $prop->addAttribute(Column::class, ['type' => 'date', 'options' => ['nullable' => true]]);
        } elseif ($type === 'json_array') {
            $prop->addAttribute(Column::class, ['type' => 'json', 'options' => ['nullable' => true]]);
        } else {
            $prop->addAttribute(Column::class, []);
        }

        return $prop;
    }

    /**
     * Make a Property object for a relation field.
     *
     * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/Property.html
     *
     * @param array<mixed> $type
     */
    protected function makeSnippetEntityCollectionProp(string $name, array $type): Property
    {
        $prop = new Property($name);
        $prop->setProtected();
        $prop->setType('Collection');

        if (
            isset($type['type'])
            && $type['type'] === ClassMetadata::ONE_TO_ONE || $type['type'] === ClassMetadata::MANY_TO_ONE
        ) {
            $targetEntityName = $this->getRelationTargetEntityName($type);
            $prop->setType($targetEntityName);
        } else {
            $prop->setType(Collection::class);
        }

        $attributes = [];
        $attributes[] = $this->makeRelationAttribute($type);

        $joinTable = $this->makeJoinTableAttributes($type);
        if ($joinTable) {
            $attributes[] = $joinTable;
        }

        $attributes = array_merge($attributes, $this->makeJoinColumnAttributes($type));

        $attributes = array_merge($attributes, $this->makeJoinColumnAttributes($type, true));

        $prop->setAttributes($attributes);

        return $prop;
    }

    /**
     * Make the attribute defining the relation (ex: #[ORM\ManyToMany(...)]).
     *
     * @param array<string | array<string | array<string>>> $type
     */
    protected function makeRelationAttribute(array $type): Attribute
    {
        $options = [];
        if (isset($type['mappedBy'])) {
            $options['mappedBy'] = $type['mappedBy'];
        }

        if (isset($type['targetEntity'])) {
            $options['targetEntity'] = $this->entityUtils->getEntityNameFromFullName($type['targetEntity']).'::class';
        }

        if (isset($type['cascade'])) {
            $options['cascade'] = $type['cascade'];
        }

        if (isset($type['inversedBy'])) {
            $options['inversedBy'] = $type['inversedBy'];
        }

        if (isset($type['orphanRemoval']) && ($type['type'] === ClassMetadata::ONE_TO_MANY || $type['type'] === ClassMetadata::MANY_TO_MANY)) {
            $options['orphanRemoval'] = $type['orphanRemoval'];
        }

        $relationClassNames = [
            ClassMetadata::ONE_TO_MANY => OneToMany::class,
            ClassMetadata::MANY_TO_MANY => ManyToMany::class,
            ClassMetadata::MANY_TO_ONE => ManyToOne::class,
            ClassMetadata::ONE_TO_ONE => OneToOne::class,
        ];

        return new Attribute($relationClassNames[$type['type']], $options);
    }

    /**
     * Make the #[ORM\JoinTable] attribute (if a definition exists).
     *
     * @param array<string | array<string | array<string>>> $type
     */
    protected function makeJoinTableAttributes(array $type): ?Attribute
    {
        if (!isset($type['joinTable'])) {
            return null;
        }

        $options = [];
        if (isset($type['joinTable']['name']) && $type['joinTable']['name']) {
            $options['name'] = $type['joinTable']['name'];
        }

        return new Attribute(JoinTable::class, $options);
    }

    /**
     * Make the #[JoinColumn] attributes, if definitions exists.
     *
     * Is `$inverse` is true, make the #[InverseJoinColumn] instead.
     *
     * @param array<string | array<string | array<string>>> $type
     *
     * @return array<Attribute>
     */
    protected function makeJoinColumnAttributes(array $type, bool $inverse = false): array
    {
        $key = $inverse ? 'inverseJoinColumns' : 'joinColumns';

        $definition = $type[$key] ?? $type['joinTable'][$key] ?? [];
        if (empty($definition)) {
            return [];
        }

        $attributes = [];

        foreach ($definition as $joinColDefinition) {
            $options = [];

            if (isset($joinColDefinition['name']) && $joinColDefinition['name'] !== $type['fieldName'].'_id') {
                $options['name'] = $joinColDefinition['name'];
            }
            if (($joinColDefinition['unique'] ?? false) === true) {
                $options['unique'] = true;
            }
            if (($joinColDefinition['nullable'] ?? true) === false) {
                $options['nullable'] = false;
            }
            if (($joinColDefinition['onDelete'] ?? null) !== null) {
                $options['onDelete'] = $joinColDefinition['onDelete'];
            }
            if (($joinColDefinition['columnDefinition'] ?? null) !== null) {
                $options['columnDefinition'] = $joinColDefinition['columnDefinition'];
            }
            if (($joinColDefinition['referencedColumnName'] ?? 'id') !== 'id') {
                $options['referencedColumnName'] = $joinColDefinition['referencedColumnName'];
            }

            if (empty($options)) {
                // Useless attribute
                continue;
            }

            $attributes[] = new Attribute(
                $inverse ? InverseJoinColumn::class : JoinColumn::class,
                $options
            );
        }

        return $attributes;
    }

    /**
     * Make the '__construct' method with collections initialization.
     *
     * @param array<Property> $collections
     */
    protected function makeSnippetConstructor(array $collections): Method
    {
        $constructor = new Method('__construct');
        $constructor->setPublic();

        foreach ($collections as $collection) {
            $constructor->addBody('$this->'.$collection->getName().' = new ArrayCollection();');
        }

        return $constructor;
    }

    /**
     * Make a 'getter' method for the given property.
     */
    protected function makeSnippetGetterForProp(Property $prop): Method
    {
        $method = new Method('get'.ucfirst($prop->getName()));
        $method->setReturnType($prop->getType());
        $method->setBody('return $this->'.$prop->getName().';');

        return $method;
    }

    /**
     * Make a 'setter' method for the given property.
     */
    protected function makeSnippetSetterForProp(Property $prop): Method
    {
        $method = new Method('set'.ucfirst($prop->getName()));

        $parameter = new Parameter($prop->getName());
        $parameter->setType($prop->getType());
        $method->setParameters([$parameter]);

        $method->setReturnType('self');
        $method->setBody(
            implode(
                "\n",
                [
                    '$this->'.$prop->getName().' = $'.$prop->getName().';',
                    'return $this;',
                ]
            )
        );

        return $method;
    }

    protected function getTargetEntityNameFromCollectionProp(Property $prop): ?string
    {
        if ($prop->getType() !== Collection::class) {
            throw new \LogicException('The property must be a collection');
        }

        foreach ($prop->getAttributes() as $attribute) {
            if (
                $attribute instanceof Attribute
                && ($attribute->getName() === OneToMany::class || $attribute->getName() === ManyToMany::class)
            ) {
                $targetEntityName = $attribute->getArguments()['targetEntity'];
                if (!$targetEntityName) {
                    return null;
                }

                // Normalize result (it could be a FQN, or a '::class' notation)
                $targetEntityName = str_replace('::class', '', $targetEntityName);

                if (!str_contains($targetEntityName, '\\')) {
                    try {
                        $targetEntityName = $this->entityUtils->getFullNameFromEntityName($targetEntityName);
                    } catch (\LogicException) {
                        return null;
                    }
                }

                return $targetEntityName;
            }
        }

        return null;
    }

    protected function getInverseSetterCallFromCollectionProp(Property $prop, bool $isRemoving = false): ?string
    {
        if (
            $prop->getType() !== Collection::class
        ) {
            throw new \LogicException('The property must be a collection');
        }

        $relationAttr = null;
        foreach ($prop->getAttributes() as $attribute) {
            if ($attribute->getName() === OneToMany::class || $attribute->getName() === ManyToMany::class) {
                $relationAttr = $attribute;
            }
        }

        if (!$relationAttr) {
            throw new \LogicException('Missing relation attribute for collection property '.$prop->getName());
        }

        $inversedBy = $relationAttr->getArguments()['inversedBy'] ?? $relationAttr->getArguments()['mappedBy'] ?? null;
        if (!$inversedBy) {
            var_dump('Could not determine the inverse prop for collection property '.$prop->getName());
            $inversedBy = 'XXXX';
        }

        $attr = $prop->getAttributes()[0];

        if ($attr->getName() === OneToMany::class) {
            $prefix = 'set';
        } else {
            $prefix = $isRemoving ? 'remove' : 'add';
            $inversedBy = $this->singularize($inversedBy);
        }

        return
            $prefix.
            ucfirst($inversedBy).
            (($prefix === 'set' && $isRemoving) ? '(null)' : '($this)');
    }

    /**
     * Make an 'adder' method for the given property.
     */
    protected function makeSnippetAdderForCollection(Property $prop): Method
    {
        $singularPropName = $this->singularize($prop->getName());

        $method = new Method('add'.ucfirst($singularPropName));

        $targetEntityName = $this->getTargetEntityNameFromCollectionProp($prop);

        $parameter = new Parameter($singularPropName);
        $parameter->setType($targetEntityName ?? 'mixed');
        $method->setParameters([$parameter]);

        $inverseSetterCall = $this->getInverseSetterCallFromCollectionProp($prop);

        $method->setReturnType('self');
        $method->setBody(implode(
            "\n",
            [
                'if (!$this->'.$prop->getName().'->contains($'.$singularPropName.')) {',
                '    $this->'.$prop->getName().'[] = $'.$singularPropName.';',
                '    $'.$singularPropName.'->'.$inverseSetterCall.';',
                '}',
                '',
                'return $this;',
            ]));

        return $method;
    }

    /**
     * Make a 'remover' method for the given property.
     */
    protected function makeSnippetRemoverForCollection(Property $prop): Method
    {
        $singularPropName = $this->singularize($prop->getName());

        $method = new Method('remove'.ucfirst($singularPropName));

        $targetEntityName = $this->getTargetEntityNameFromCollectionProp($prop);

        $parameter = new Parameter($singularPropName);
        $parameter->setType($targetEntityName ?? 'mixed');
        $method->setParameters([$parameter]);

        $inverseSetterCall = $this->getInverseSetterCallFromCollectionProp($prop, true);

        $method->setReturnType('self');
        $method->setBody(
            implode(
                "\n",
                [
                    'if ($this->'.$prop->getName().'->removeElement($'.$singularPropName.')) {',
                    '    $'.$singularPropName.'->'.$inverseSetterCall.';',
                    '}',
                    '',
                    'return $this;',
                ]
            )
        );

        return $method;
    }

    /**
     * Perform some post-fixes on the file content.
     */
    protected function postProcessFileContent(string $content): string
    {
        return preg_replace("/targetEntity: '(\w+)::class'/", 'targetEntity: $1::class', $content);
    }

    protected function singularize(string $name): string
    {
        $exceptions = ['access'];
        if (in_array(strtolower($name), $exceptions)) {
            return $name;
        }

        return preg_replace('/s$/', '', $name);
    }
}
