<?php

declare(strict_types=1);

namespace App\Service\Doctrine\SchemaValidation;

use App\Service\ApiLegacy\ApiLegacyRequestService;
use App\Service\Utils\EntityUtils;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\MappingException;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;

/**
 * Validation du schéma Doctrine par comparaison aux entités en production sur la V1.
 *
 * — À supprimer lorsque la migration sera achevée —
 */
class SchemaValidationService
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly ApiLegacyRequestService $apiLegacyRequestService,
        private readonly EntityUtils $entityUtils,
    ) {
    }

    /**
     * Compare the V2 doctrine schema to the one in V1, and return a list of differences,
     * of the form:
     *
     *     [<entity> → Difference | [<field> → Difference]]
     *
     * @return array<string, Difference|array<Difference>>
     *
     * @throws ClientExceptionInterface
     * @throws MappingException
     * @throws RedirectionExceptionInterface
     * @throws ServerExceptionInterface
     * @throws TransportExceptionInterface
     */
    public function validateSchema(?DiffTypeEnum $filter = null): array
    {
        $schemaV1 = $this->getV1Schema();
        $schemaV2 = $this->getV2Schema();

        return $this->getDiff($schemaV1, $schemaV2, $filter);
    }

    /**
     * Retrieve the V2 schema.
     *
     * @return array<string, array<string|array<string|int>>>
     *
     * @throws MappingException
     */
    protected function getV2Schema(): array
    {
        $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata();
        $schema = [];

        foreach ($metadata as $entityMetadata) {
            $entityClassName = $this->entityUtils->getEntityNameFromFullName($entityMetadata->getName());

            $schema[$entityClassName] = [];

            foreach ($entityMetadata->getFieldNames() as $field) {
                $schema[$entityClassName][$field] = $entityMetadata->getTypeOfField($field);
            }

            foreach ($entityMetadata->getAssociationNames() as $association) {
                $schema[$entityClassName][$association] = $entityMetadata->getAssociationMapping($association);
            }
        }

        return $schema;
    }

    /**
     * Retrieve the V1 schema.
     *
     * @return array<string, array<string|array<string|int>>>
     *
     * @throws ClientExceptionInterface
     * @throws RedirectionExceptionInterface
     * @throws ServerExceptionInterface
     * @throws TransportExceptionInterface
     */
    protected function getV1Schema(): array
    {
        $response = $this->apiLegacyRequestService->get('/_internal/doctrine/schema');

        return json_decode($response->getContent(), true);
    }

    /**
     * Get a list of differences between V1 and V2 doctrine schemas.
     *
     * @param array<string, array<string|array<string|int>>> $schemaV1
     * @param array<string, array<string|array<string|int>>> $schemaV2
     *
     * @return array<string, Difference|array<Difference>>
     */
    protected function getDiff(array $schemaV1, array $schemaV2, ?DiffTypeEnum $filter = null): array
    {
        $diff = [
        ];

        foreach ($schemaV1 as $entity => $fields) {
            if (!$this->isEntityInSchema($schemaV2, $entity)) {
                // L'entité n'existe pas en V2
                if (!$filter || $filter === DiffTypeEnum::MISSING_ENTITY) {
                    $diff[$entity] = new Difference(
                        DiffTypeEnum::MISSING_ENTITY,
                        "Entity `$entity` is missing in V2",
                        $fields
                    );
                }
                continue;
            }

            foreach ($fields as $field => $fieldTypeV1) {
                if (
                    !$this->isPropertyInSchema($schemaV2, $entity, $field)
                    && $this->isRelationField($schemaV1, $entity, $field)
                    && $this->isPropertyInSchema($schemaV2, $entity, $field.'s')
                ) {
                    // Le champ existe en V2, mais il a été mis au pluriel, par exemple : $contactPoint devenu $contactPoints
                    // Pour éviter les faux positifs, on renomme le champ dans le schéma V1
                    $schemaV1[$entity][$field.'s'] = $fieldTypeV1;
                    unset($schemaV1[$entity][$field]);
                    $fields = $schemaV1[$entity];
                }
            }

            $diff[$entity] = [];

            foreach ($fields as $field => $fieldTypeV1) {
                if (!$this->isPropertyInSchema($schemaV2, $entity, $field)) {
                    // Le champ n'existe pas en V2
                    if ($this->isRelationField($schemaV1, $entity, $field)) {
                        $diff[$entity][$field] = new Difference(
                            DiffTypeEnum::MISSING_RELATION,
                            'Relation '.$this->getRelationTypeLabel($fieldTypeV1)." `$field` is missing in V2",
                            $fieldTypeV1,
                        );
                    } else {
                        $diff[$entity][$field] = new Difference(
                            DiffTypeEnum::MISSING_PROPERTY,
                            "Property `$field` is missing in V2",
                            $fieldTypeV1
                        );
                    }
                    continue;
                }

                $fieldTypeV2 = $schemaV2[$entity][$field];

                // Si ce champ n'est pas une relation
                if (!$this->isRelationField($schemaV1, $entity, $field)) {
                    // Le champ n'est pas une relation en V1
                    if ($fieldTypeV2 !== $fieldTypeV1) {
                        // Le champ a un type différent en V2
                        $diff[$entity][$field] = new Difference(
                            DiffTypeEnum::DIFFERENT_TYPE,
                            "Property `$field` has a different type (V1: `$fieldTypeV1`, V2: `$fieldTypeV2`)",
                            $fieldTypeV1
                        );
                    }
                } elseif (!$this->isRelationField($schemaV2, $entity, $field)) {
                    // Le champ est une relation en V1 mais pas en V2
                    $diff[$entity][$field] = new Difference(
                        DiffTypeEnum::DIFFERENT_TYPE,
                        "Property $field is a relation in V1 but not in V2",
                        $fieldTypeV1
                    );
                } else {
                    // Le champ est une relation dans les deux schémas, on compare leurs configurations
                    $difference = $this->getRelationDiff($fieldTypeV1, $fieldTypeV2);
                    if ($difference) {
                        $diff[$entity][$field] = $difference;
                    }
                }
            }

            // Si filter est non null, on ne conserve que les différences du type donné
            if ($filter !== null) {
                $diff[$entity] = array_filter(
                    $diff[$entity],
                    function (Difference $difference) use ($filter) {
                        return $difference->getType() === $filter;
                    }
                );
            }
        }

        return $diff;
    }

    /**
     * Returns true if the given entity name exists in the doctrine schema.
     *
     * @param array<string, array<string|array<string|int>>> $schema
     */
    protected function isEntityInSchema(array $schema, string $entity): bool
    {
        return isset($schema[$entity]);
    }

    /**
     * Returns true if the given property name exists in the doctrine schema under this entity.
     *
     * @param array<string, array<string|array<string|int>>> $schema
     */
    protected function isPropertyInSchema(array $schema, string $entity, string $property): bool
    {
        return $this->isEntityInSchema($schema, $entity) && isset($schema[$entity][$property]);
    }

    /**
     * Is the given field a relation field.
     *
     * @param array<string, array<string|array<string|int>>> $schema
     */
    protected function isRelationField(array $schema, string $entity, string $relation): bool
    {
        return $this->isPropertyInSchema($schema, $entity, $relation) && is_array($schema[$entity][$relation]);
    }

    /**
     * Look up for differences in $relationCompared compared to $relationReference, and return
     * a Difference if any, or null else.
     *
     * @param array<string, string> $relationReference
     * @param array<string, string> $relationCompared
     */
    protected function getRelationDiff(array $relationReference, array $relationCompared): ?Difference
    {
        if ($relationReference['type'] !== $relationCompared['type']) {
            return new Difference(
                DiffTypeEnum::DIFFERENT_RELATION_TYPE,
                "Relation type is different : {$this->getRelationTypeLabel($relationReference)} !== {$this->getRelationTypeLabel($relationCompared)}",
                $relationReference
            );
        }

        if (
            $this->entityUtils->getEntityNameFromFullName($relationReference['targetEntity']) !== $this->entityUtils->getEntityNameFromFullName($relationCompared['targetEntity'])
        ) {
            return new Difference(
                DiffTypeEnum::DIFFERENT_RELATION_CONFIGURATION,
                'Relation configuration is different (targetEntity)',
                $relationReference
            );
        }

        if (
            $relationReference['mappedBy'] !== $relationCompared['mappedBy']
        ) {
            return new Difference(
                DiffTypeEnum::DIFFERENT_RELATION_CONFIGURATION,
                'Relation configuration is different (mappedBy)',
                $relationReference
            );
        }

        return null;
    }

    /**
     * Get the name of a relation from a ClassMetadataInfo integer constant.
     *
     * @param array<string, string|int> $relation
     */
    protected function getRelationTypeLabel(array $relation): string
    {
        return [
            ClassMetadata::ONE_TO_ONE => 'OneToOne',
            ClassMetadata::MANY_TO_ONE => 'ManyToOne',
            ClassMetadata::ONE_TO_MANY => 'OneToMany',
            ClassMetadata::MANY_TO_MANY => 'ManyToMany',
        ][$relation['type']] ?? 'Unknown';
    }

    /**
     * @param array<Difference|array<Difference>> $diff
     *
     * @return array<string>
     */
    public function formatToCsv(array $diff): array
    {
        $csv = [];
        foreach ($diff as $entity => $differences) {
            if (!is_array($differences)) {
                $csv[] = implode(';', [$entity, '', $differences->getType()->value]);
            } else {
                foreach ($differences as $field => $difference) {
                    $csv[] = implode(';', [$entity, $field, $difference->getType()->value]);
                }
            }
        }

        return $csv;
    }
}
