<?php

declare(strict_types=1);

/*
 * Chill is a software for social workers
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace Chill\DocGeneratorBundle\Serializer\Normalizer;

use Chill\DocGeneratorBundle\Serializer\Helper\NormalizeNullValueHelper;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\Common\Collections\ReadableCollection;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessor;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\Mapping\AttributeMetadata;
use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface;
use Symfony\Component\Serializer\Mapping\ClassMetadata;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

class DocGenObjectNormalizer implements NormalizerAwareInterface, NormalizerInterface
{
    use NormalizerAwareTrait;

    private readonly PropertyAccessor $propertyAccess;

    public function __construct(
        private readonly ClassMetadataFactoryInterface $classMetadataFactory,
        private readonly TranslatableStringHelperInterface $translatableStringHelper,
    ) {
        $this->propertyAccess = PropertyAccess::createPropertyAccessor();
    }

    public function normalize($object, $format = null, array $context = [])
    {
        $classMetadataKey = $object ?? $context['docgen:expects'] ?? null;

        if (null === $classMetadataKey) {
            throw new \RuntimeException('Could not determine the metadata for this object. Either provide a non-null object, or a "docgen:expects" key in the context');
        }

        if (!$this->classMetadataFactory->hasMetadataFor($classMetadataKey)) {
            throw new LogicException(sprintf('This object does not have metadata: %s. Add groups on this entity to allow to serialize with the format %s and groups %s', \is_object($object) ? $object::class : '(todo' /* $context['docgen:expects'], */ , $format, \implode(', ', $context['groups'] ?? [])));
        }

        $metadata = $this->classMetadataFactory->getMetadataFor($classMetadataKey);
        if (!$metadata instanceof ClassMetadata) {
            throw new \LogicException('ClassMetadata should be the only one implementation for ClassMetadataInterface. See https://github.com/symfony/symfony/pull/17114');
        }
        $expectedGroups = \array_key_exists(AbstractNormalizer::GROUPS, $context) ?
            \is_array($context[AbstractNormalizer::GROUPS]) ? $context[AbstractNormalizer::GROUPS] : [$context[AbstractNormalizer::GROUPS]]
            : [];
        $attributes = \array_filter(
            $metadata->getAttributesMetadata(),
            static function (AttributeMetadataInterface $a) use ($expectedGroups) {
                foreach ($a->getGroups() as $g) {
                    if (\in_array($g, $expectedGroups, true)) {
                        return true;
                    }
                }

                return false;
            }
        );

        if (null === $object) {
            return $this->normalizeNullData($format, $context, $metadata, $attributes);
        }

        return $this->normalizeObject($object, $format, $context, $expectedGroups, $metadata, $attributes);
    }

    public function supportsNormalization($data, $format = null): bool
    {
        return 'docgen' === $format && (\is_object($data) || null === $data);
    }

    private function getExpectedType(AttributeMetadata $attribute, \ReflectionClass $reflection): string
    {
        $type = null;

        do {
            // we have to get the expected content
            if ($reflection->hasProperty($attribute->getName())) {
                if (!$reflection->getProperty($attribute->getName())->hasType()) {
                    throw new \LogicException(sprintf('Could not determine how the content is determined for the attribute %s on class %s. Add a type on this property', $attribute->getName(), $reflection->getName()));
                }

                $type = $reflection->getProperty($attribute->getName())->getType();
            } elseif ($reflection->hasMethod($method = 'get'.ucfirst($attribute->getName()))) {
                if (!$reflection->getMethod($method)->hasReturnType()) {
                    throw new \LogicException(sprintf('Could not determine how the content is determined for the attribute %s on class %s. Add a return type on the method', $attribute->getName(), $reflection->getName()));
                }

                $type = $reflection->getMethod($method)->getReturnType();
            } elseif ($reflection->hasMethod($method = 'is'.ucfirst($attribute->getName()))) {
                if (!$reflection->getMethod($method)->hasReturnType()) {
                    throw new \LogicException(sprintf('Could not determine how the content is determined for the attribute %s on class %s. Add a return type on the method', $attribute->getName(), $reflection->getName()));
                }

                $type = $reflection->getMethod($method)->getReturnType();
            } elseif ($reflection->hasMethod($attribute->getName())) {
                if (!$reflection->getMethod($attribute->getName())->hasReturnType()) {
                    throw new \LogicException(sprintf('Could not determine how the content is determined for the attribute %s on class %s. Add a return type on the method', $attribute->getName(), $reflection->getName()));
                }

                $type = $reflection->getMethod($attribute->getName())->getReturnType();
            } else {
                $reflection = $reflection->getParentClass();
            }
        } while (null === $type && $reflection instanceof \ReflectionClass);

        if ($type instanceof \ReflectionNamedType) {
            return $type->getName();
        }
        if ($type instanceof \ReflectionIntersectionType) {
            foreach (array_map(fn (\ReflectionType $t) => $t->getName(), $type->getTypes()) as $classString) {
                if (ReadableCollection::class === $classString) {
                    return ReadableCollection::class;
                }

                $class = new \ReflectionClass($classString);

                if ($class->implementsInterface(ReadableCollection::class)) {
                    return ReadableCollection::class;
                }
            }

            throw new \LogicException(sprintf('The automatic normalization of intersection types is not supported, unless a %s is contained in the intersected types', ReadableCollection::class));
        } elseif (null === $type ?? null) {
            throw new \LogicException(sprintf('Could not determine the type for this attribute: %s. Add a return type to the method or property declaration', $attribute->getName()));
        }

        throw new \LogicException(sprintf('The automatic normalization of %s is not supported', $type::class));
    }

    /**
     * @param array|AttributeMetadata[] $attributes
     */
    private function normalizeNullData(string $format, array $context, ClassMetadata $metadata, array $attributes): array
    {
        $keys = [];

        // add a discriminator
        if (null !== $discriminator = $metadata->getClassDiscriminatorMapping()) {
            $typeKey = $discriminator->getTypeProperty();
            $typeValue = null;

            foreach ($discriminator->getTypesMapping() as $type => $typeClass) {
                if ($typeClass === $context['docgen:expects']) {
                    $typeValue = $type;

                    break;
                }
            }

            if (null === $typeValue) {
                $typeKey = null;
            }
        } else {
            $typeKey = $typeValue = null;
        }

        foreach ($attributes as $attribute) {
            $key = $attribute->getSerializedName() ?? $attribute->getName();
            $keys[$key] = $this->getExpectedType($attribute, $metadata->getReflectionClass());
        }

        $normalizer = new NormalizeNullValueHelper($this->normalizer, $typeKey, $typeValue);

        return $normalizer->normalize($keys, $format, $context, $metadata);
    }

    private function normalizeNullOutputValue(mixed $format, array $context, AttributeMetadata $attribute, \ReflectionClass $reflection)
    {
        $type = $this->getExpectedType($attribute, $reflection);

        switch ($type) {
            case 'array':
                if (\in_array('is-translatable', $attribute->getNormalizationContextForGroups(['docgen:read']), true)) {
                    return '';
                }

                return [];

            case 'bool':
            case 'double':
            case 'float':
            case 'int':
            case 'resource':
                return null;

            case 'string':
                return '';

            default:
                return $this->normalizer->normalize(
                    null,
                    $format,
                    \array_merge(
                        $context,
                        ['docgen:expects' => $type]
                    )
                );
        }
    }

    /**
     * @param array<AttributeMetadata> $attributes
     *
     * @return array
     *
     * @throws ExceptionInterface
     */
    private function normalizeObject($object, $format, array $context, array $expectedGroups, ClassMetadata $metadata, array $attributes)
    {
        $data = [];
        $data['isNull'] = false;
        $reflection = $metadata->getReflectionClass();

        // add a discriminator
        if (null !== $discriminator = $metadata->getClassDiscriminatorMapping()) {
            $data[$discriminator->getTypeProperty()] = $discriminator->getMappedObjectType($object);
        }

        foreach ($attributes as $attribute) {
            /** @var AttributeMetadata $attribute */
            $value = $this->propertyAccess->getValue($object, $attribute->getName());
            $key = $attribute->getSerializedName() ?? $attribute->getName();
            $objectContext = \array_merge(
                $context,
                $attribute->getNormalizationContextForGroups(
                    \is_array($context['groups']) ? $context['groups'] : [$context['groups']]
                )
            );
            $isTranslatable = $objectContext['is-translatable'] ?? false;

            if ($isTranslatable) {
                $data[$key] = $this->translatableStringHelper
                    ->localize($value);
            } elseif ($value instanceof ReadableCollection) {
                // when normalizing collection, we should not preserve keys (to ensure that the result is a list)
                // this is why we make call to the normalizer again to use the CollectionDocGenNormalizer
                $data[$key] =
                    $this->normalizer->normalize($value, $format, \array_merge(
                        $objectContext,
                        $attribute->getNormalizationContextForGroups($expectedGroups)
                    ));
            } elseif (is_iterable($value)) {
                $arr = [];

                foreach ($value as $k => $v) {
                    $arr[$k] =
                        $this->normalizer->normalize($v, $format, \array_merge(
                            $objectContext,
                            $attribute->getNormalizationContextForGroups($expectedGroups)
                        ));
                }
                $data[$key] = $arr;
            } elseif (\is_object($value)) {
                $data[$key] =
                    $this->normalizer->normalize($value, $format, \array_merge(
                        $objectContext,
                        $attribute->getNormalizationContextForGroups($expectedGroups)
                    ));
            } elseif (null === $value) {
                $data[$key] = $this->normalizeNullOutputValue($format, $objectContext, $attribute, $reflection);
            } else {
                $data[$key] = $value;
            }
        }

        return $data;
    }
}
