<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Component\Validator\Mapping\Loader;

use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyListExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
use Symfony\Component\TypeInfo\Type as TypeInfoType;
use Symfony\Component\TypeInfo\Type\BuiltinType;
use Symfony\Component\TypeInfo\Type\CollectionType;
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
use Symfony\Component\TypeInfo\Type\NullableType;
use Symfony\Component\TypeInfo\Type\ObjectType;
use Symfony\Component\TypeInfo\Type\WrappingTypeInterface;
use Symfony\Component\TypeInfo\TypeIdentifier;
use Symfony\Component\Validator\Constraints\All;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;
use Symfony\Component\Validator\Constraints\Type;
use Symfony\Component\Validator\Mapping\AutoMappingStrategy;
use Symfony\Component\Validator\Mapping\ClassMetadata;

/**
 * Guesses and loads the appropriate constraints using PropertyInfo.
 *
 * @author Kévin Dunglas <dunglas@gmail.com>
 */
final class PropertyInfoLoader implements LoaderInterface
{
    use AutoMappingTrait;

    public function __construct(
        private PropertyListExtractorInterface $listExtractor,
        private PropertyTypeExtractorInterface $typeExtractor,
        private PropertyAccessExtractorInterface $accessExtractor,
        private ?string $classValidatorRegexp = null,
    ) {
    }

    public function loadClassMetadata(ClassMetadata $metadata): bool
    {
        $className = $metadata->getClassName();
        if (!$properties = $this->listExtractor->getProperties($className)) {
            return false;
        }

        $loaded = false;
        $enabledForClass = $this->isAutoMappingEnabledForClass($metadata, $this->classValidatorRegexp);
        foreach ($properties as $property) {
            if (false === $this->accessExtractor->isWritable($className, $property)) {
                continue;
            }

            if (!property_exists($className, $property)) {
                continue;
            }

            $type = $this->typeExtractor->getType($className, $property);
            if (null === $type) {
                continue;
            }

            $enabledForProperty = $enabledForClass;
            $hasTypeConstraint = false;
            $hasNotNullConstraint = false;
            $hasNotBlankConstraint = false;
            $allConstraint = null;
            foreach ($metadata->getPropertyMetadata($property) as $propertyMetadata) {
                // Enabling or disabling auto-mapping explicitly always takes precedence
                if (AutoMappingStrategy::DISABLED === $propertyMetadata->getAutoMappingStrategy()) {
                    continue 2;
                }

                if (AutoMappingStrategy::ENABLED === $propertyMetadata->getAutoMappingStrategy()) {
                    $enabledForProperty = true;
                }

                foreach ($propertyMetadata->getConstraints() as $constraint) {
                    if ($constraint instanceof Type) {
                        $hasTypeConstraint = true;
                    } elseif ($constraint instanceof NotNull) {
                        $hasNotNullConstraint = true;
                    } elseif ($constraint instanceof NotBlank) {
                        $hasNotBlankConstraint = true;
                    } elseif ($constraint instanceof All) {
                        $allConstraint = $constraint;
                    }
                }
            }

            if (!$enabledForProperty) {
                continue;
            }

            $loaded = true;

            if ($hasTypeConstraint) {
                continue;
            }

            $nullable = $type->isNullable();

            if ($type instanceof NullableType) {
                $type = $type->getWrappedType();
            }

            if ($type instanceof NullableType) {
                $type = $type->getWrappedType();
            }

            if ($type instanceof CollectionType) {
                $this->handleAllConstraint($property, $allConstraint, $type->getCollectionValueType(), $metadata);
            }

            if (null !== $typeConstraint = $this->getTypeConstraint($type)) {
                $metadata->addPropertyConstraint($property, $typeConstraint);
            }

            if (!$nullable && !$hasNotBlankConstraint && !$hasNotNullConstraint) {
                $metadata->addPropertyConstraint($property, new NotNull());
            }
        }

        return $loaded;
    }

    private function getTypeConstraint(TypeInfoType $type): ?Type
    {
        if ($type instanceof CompositeTypeInterface) {
            return $type->isIdentifiedBy(
                TypeIdentifier::INT,
                TypeIdentifier::FLOAT,
                TypeIdentifier::STRING,
                TypeIdentifier::BOOL,
                TypeIdentifier::TRUE,
                TypeIdentifier::FALSE,
            ) ? new Type(type: 'scalar') : null;
        }

        while ($type instanceof WrappingTypeInterface) {
            $type = $type->getWrappedType();
        }

        if ($type instanceof ObjectType) {
            return new Type(type: $type->getClassName());
        }

        if ($type instanceof BuiltinType && TypeIdentifier::MIXED !== $type->getTypeIdentifier()) {
            return new Type(type: $type->getTypeIdentifier()->value);
        }

        return null;
    }

    private function handleAllConstraint(string $property, ?All $allConstraint, TypeInfoType $type, ClassMetadata $metadata): void
    {
        $containsTypeConstraint = false;
        $containsNotNullConstraint = false;
        if (null !== $allConstraint) {
            foreach ($allConstraint->constraints as $constraint) {
                if ($constraint instanceof Type) {
                    $containsTypeConstraint = true;
                } elseif ($constraint instanceof NotNull) {
                    $containsNotNullConstraint = true;
                }
            }
        }

        $constraints = [];
        if (!$containsNotNullConstraint && !$type->isNullable()) {
            $constraints[] = new NotNull();
        }

        if (!$containsTypeConstraint && null !== $typeConstraint = $this->getTypeConstraint($type)) {
            $constraints[] = $typeConstraint;
        }

        if (!$constraints) {
            return;
        }

        if (null === $allConstraint) {
            $metadata->addPropertyConstraint($property, new All(constraints: $constraints));
        } else {
            $allConstraint->constraints = array_merge($allConstraint->constraints, $constraints);
        }
    }
}
