<?php

declare(strict_types=1);

/*
 * This file is part of the Sonata Project package.
 *
 * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Sonata\AdminBundle\Form\ChoiceList;

use Doctrine\Common\Util\ClassUtils;
use Doctrine\ORM\QueryBuilder;
use ReflectionProperty;
use Sonata\AdminBundle\Model\ModelManagerInterface;
use Symfony\Component\Form\Exception\InvalidArgumentException;
use Symfony\Component\Form\Exception\RuntimeException;
use Symfony\Component\Form\Extension\Core\ChoiceList\SimpleChoiceList;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyAccess\PropertyPath;

@trigger_error(
    'The '.__NAMESPACE__.'\ModelChoiceList class is deprecated since 3.24 and will be removed in 4.0.'
    .' Use '.__NAMESPACE__.'\ModelChoiceLoader instead.',
    E_USER_DEPRECATED
);

/**
 * NEXT_MAJOR: Remove this class.
 *
 * @deprecated since sonata-project/admin-bundle 3.24, to be removed in 4.0. Use Sonata\AdminBundle\ModelChoiceLoader instead
 *
 * @author Thomas Rabaix <thomas.rabaix@sonata-project.org>
 */
class ModelChoiceList extends SimpleChoiceList
{
    /**
     * @var ModelManagerInterface
     */
    private $modelManager;

    /**
     * @var string
     */
    private $class;

    /**
     * The entities from which the user can choose.
     *
     * This array is either indexed by ID (if the ID is a single field)
     * or by key in the choices array (if the ID consists of multiple fields)
     *
     * This property is initialized by initializeChoices(). It should only
     * be accessed through getEntity() and getEntities().
     *
     * @var mixed
     */
    private $entities = [];

    /**
     * Contains the query builder that builds the query for fetching the
     * entities.
     *
     * This property should only be accessed through queryBuilder.
     *
     * @var QueryBuilder
     */
    private $query;

    /**
     * The fields of which the identifier of the underlying class consists.
     *
     * This property should only be accessed through identifier.
     *
     * @var array
     */
    private $identifier = [];

    /**
     * A cache for \ReflectionProperty instances for the underlying class.
     *
     * This property should only be accessed through getReflProperty().
     *
     * @var array
     */
    private $reflProperties = [];

    /**
     * @var PropertyPath
     */
    private $propertyPath;

    /**
     * @var PropertyAccessorInterface
     */
    private $propertyAccessor;

    /**
     * @param string            $class
     * @param string|null       $property
     * @param QueryBuilder|null $query
     * @param array             $choices
     */
    public function __construct(
        ModelManagerInterface $modelManager,
        $class,
        $property = null,
        $query = null,
        $choices = [],
        PropertyAccessorInterface $propertyAccessor = null
    ) {
        $this->modelManager = $modelManager;
        $this->class = $class;
        $this->query = $query;
        $this->identifier = $this->modelManager->getIdentifierFieldNames($this->class);

        // The property option defines, which property (path) is used for
        // displaying entities as strings
        if ($property) {
            $this->propertyPath = new PropertyPath($property);
            $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
        }

        parent::__construct($this->load($choices));
    }

    /**
     * @return array
     */
    public function getIdentifier()
    {
        return $this->identifier;
    }

    /**
     * Returns the according entities for the choices.
     *
     * If the choices were not initialized, they are initialized now. This
     * is an expensive operation, except if the entities were passed in the
     * "choices" option.
     *
     * @return array An array of entities
     */
    public function getEntities()
    {
        return $this->entities;
    }

    /**
     * Returns the entity for the given key.
     *
     * If the underlying entities have composite identifiers, the choices
     * are initialized. The key is expected to be the index in the choices
     * array in this case.
     *
     * If they have single identifiers, they are either fetched from the
     * internal entity cache (if filled) or loaded from the database.
     *
     * @param string $key The choice key (for entities with composite
     *                    identifiers) or entity ID (for entities with single
     *                    identifiers)
     *
     * @return object The matching entity
     */
    public function getEntity($key)
    {
        if (\count($this->identifier) > 1) {
            // $key is a collection index
            $entities = $this->getEntities();

            return $entities[$key] ?? null;
        } elseif ($this->entities) {
            return isset($this->entities[$key]) ? $this->entities[$key] : null;
        }

        return $this->modelManager->find($this->class, $key);
    }

    /**
     * Returns the values of the identifier fields of an entity.
     *
     * Doctrine must know about this entity, that is, the entity must already
     * be persisted or added to the identity map before. Otherwise an
     * exception is thrown.
     *
     * @param object $entity The entity for which to get the identifier
     *
     * @throws InvalidArgumentException If the entity does not exist in Doctrine's
     *                                  identity map
     *
     * @return array
     */
    public function getIdentifierValues($entity)
    {
        try {
            return $this->modelManager->getIdentifierValues($entity);
        } catch (\Exception $e) {
            throw new InvalidArgumentException(sprintf('Unable to retrieve the identifier values for entity %s', ClassUtils::getClass($entity)), 0, $e);
        }
    }

    /**
     * @return ModelManagerInterface
     */
    public function getModelManager()
    {
        return $this->modelManager;
    }

    /**
     * @return string
     */
    public function getClass()
    {
        return $this->class;
    }

    /**
     * Initializes the choices and returns them.
     *
     * The choices are generated from the entities. If the entities have a
     * composite identifier, the choices are indexed using ascending integers.
     * Otherwise the identifiers are used as indices.
     *
     * If the entities were passed in the "choices" option, this method
     * does not have any significant overhead. Otherwise, if a query builder
     * was passed in the "query" option, this builder is now used to construct
     * a query which is executed. In the last case, all entities for the
     * underlying class are fetched from the repository.
     *
     * If the option "property" was passed, the property path in that option
     * is used as option values. Otherwise this method tries to convert
     * objects to strings using __toString().
     *
     * @return array An array of choices
     */
    protected function load($choices)
    {
        if (\is_array($choices) && \count($choices) > 0) {
            $entities = $choices;
        } elseif ($this->query) {
            $entities = $this->modelManager->executeQuery($this->query);
        } else {
            $entities = $this->modelManager->findBy($this->class);
        }

        if (null === $entities) {
            return [];
        }

        $choices = [];
        $this->entities = [];

        foreach ($entities as $key => $entity) {
            if ($this->propertyPath) {
                // If the property option was given, use it
                $value = $this->propertyAccessor->getValue($entity, $this->propertyPath);
            } else {
                // Otherwise expect a __toString() method in the entity
                try {
                    $value = (string) $entity;
                } catch (\Exception $e) {
                    throw new RuntimeException(sprintf('Unable to convert the entity "%s" to string, provide '
                        .'"property" option or implement "__toString()" method in your entity.', ClassUtils::getClass($entity)), 0, $e);
                }
            }

            if (\count($this->identifier) > 1) {
                // When the identifier consists of multiple field, use
                // naturally ordered keys to refer to the choices
                $choices[$key] = $value;
                $this->entities[$key] = $entity;
            } else {
                // When the identifier is a single field, index choices by
                // entity ID for performance reasons
                $id = current($this->getIdentifierValues($entity));
                $choices[$id] = $value;
                $this->entities[$id] = $entity;
            }
        }

        return $choices;
    }

    /**
     * Returns the \ReflectionProperty instance for a property of the
     * underlying class.
     *
     * @param string $property The name of the property
     *
     * @return \ReflectionProperty The reflection instance
     */
    private function getReflProperty(string $property): ReflectionProperty
    {
        if (!isset($this->reflProperties[$property])) {
            $this->reflProperties[$property] = new \ReflectionProperty($this->class, $property);
            $this->reflProperties[$property]->setAccessible(true);
        }

        return $this->reflProperties[$property];
    }
}
