<?php

namespace Laminas\Form;

use ArrayAccess;
use Laminas\Hydrator;
use Laminas\InputFilter\Factory as InputFilterFactory;
use Laminas\InputFilter\InputFilterInterface;
use Laminas\ServiceManager\ServiceManager;
use Laminas\Stdlib\ArrayUtils;
use Traversable;

use function class_exists;
use function get_class;
use function gettype;
use function is_array;
use function is_object;
use function is_string;
use function method_exists;
use function sprintf;
use function trigger_error;

use const E_USER_DEPRECATED;

class Factory
{
    /**
     * @var InputFilterFactory
     */
    protected $inputFilterFactory;

    /**
     * @var FormElementManager
     */
    protected $formElementManager;

    /**
     * @param FormElementManager $formElementManager
     */
    public function __construct(
        FormElementManager $formElementManager = null,
        InputFilterFactory $inputFilterFactory = null
    ) {
        if ($formElementManager) {
            $this->setFormElementManager($formElementManager);
        }

        if ($inputFilterFactory) {
            $this->setInputFilterFactory($inputFilterFactory);
        }
    }

    /**
     * Set input filter factory to use when creating forms
     *
     * @param  InputFilterFactory $inputFilterFactory
     * @return $this
     */
    public function setInputFilterFactory(InputFilterFactory $inputFilterFactory)
    {
        $this->inputFilterFactory = $inputFilterFactory;
        return $this;
    }

    /**
     * Get current input filter factory
     *
     * If none provided, uses an unconfigured instance.
     *
     * @return InputFilterFactory
     */
    public function getInputFilterFactory()
    {
        if (null === $this->inputFilterFactory) {
            $this->setInputFilterFactory(new InputFilterFactory());
        }
        return $this->inputFilterFactory;
    }

    /**
     * Set the form element manager
     *
     * @param  FormElementManager $formElementManager
     * @return $this
     */
    public function setFormElementManager(FormElementManager $formElementManager)
    {
        $this->formElementManager = $formElementManager;
        return $this;
    }

    /**
     * Get form element manager
     *
     * @return FormElementManager
     */
    public function getFormElementManager()
    {
        if ($this->formElementManager === null) {
            $this->setFormElementManager(new FormElementManager(new ServiceManager()));
        }

        return $this->formElementManager;
    }

    /**
     * Create an element, fieldset, or form
     *
     * Introspects the 'type' key of the provided $spec, and determines what
     * type is being requested; if none is provided, assumes the spec
     * represents simply an element.
     *
     * @param  array|Traversable $spec
     * @return ElementInterface
     * @throws Exception\DomainException
     */
    public function create($spec)
    {
        $spec = $this->validateSpecification($spec, __METHOD__);
        $type = isset($spec['type']) ? $spec['type'] : Element::class;

        $element = $this->getFormElementManager()->get($type);

        if ($element instanceof FormInterface) {
            return $this->configureForm($element, $spec);
        }

        if ($element instanceof FieldsetInterface) {
            return $this->configureFieldset($element, $spec);
        }

        if ($element instanceof ElementInterface) {
            return $this->configureElement($element, $spec);
        }

        throw new Exception\DomainException(sprintf(
            '%s expects the $spec["type"] to implement one of %s, %s, or %s; received %s',
            __METHOD__,
            ElementInterface::class,
            FieldsetInterface::class,
            FormInterface::class,
            $type
        ));
    }

    /**
     * Create an element
     *
     * @param  array $spec
     * @return ElementInterface
     */
    public function createElement($spec)
    {
        if (! isset($spec['type'])) {
            $spec['type'] = Element::class;
        }

        return $this->create($spec);
    }

    /**
     * Create a fieldset
     *
     * @param  array $spec
     * @return FieldsetInterface
     */
    public function createFieldset($spec)
    {
        if (! isset($spec['type'])) {
            $spec['type'] = Fieldset::class;
        }

        return $this->create($spec);
    }

    /**
     * Create a form
     *
     * @param  array $spec
     * @return FormInterface
     */
    public function createForm($spec)
    {
        if (! isset($spec['type'])) {
            $spec['type'] = Form::class;
        }

        return $this->create($spec);
    }

    /**
     * Configure an element based on the provided specification
     *
     * Specification can contain any of the following:
     * - type: the Element class to use; defaults to \Laminas\Form\Element
     * - name: what name to provide the element, if any
     * - options: an array or Traversable object of element options
     * - attributes: an array or Traversable object of element attributes to assign
     *
     * @param  ElementInterface              $element
     * @param  array|Traversable|ArrayAccess $spec
     * @return ElementInterface
     * @throws Exception\DomainException
     */
    public function configureElement(ElementInterface $element, $spec)
    {
        $spec = $this->validateSpecification($spec, __METHOD__);

        $name       = isset($spec['name']) ? $spec['name'] : null;
        $options    = isset($spec['options']) ? $spec['options'] : null;
        $attributes = isset($spec['attributes']) ? $spec['attributes'] : null;

        if ($name !== null && $name !== '') {
            $element->setName($name);
        }

        if (is_array($options) || $options instanceof Traversable) {
            $element->setOptions($options);
        }

        if (is_array($attributes) || $attributes instanceof Traversable) {
            $element->setAttributes($attributes);
        }

        return $element;
    }

    /**
     * Configure a fieldset based on the provided specification
     *
     * Specification can contain any of the following:
     * - type: the Fieldset class to use; defaults to \Laminas\Form\Fieldset
     * - name: what name to provide the fieldset, if any
     * - options: an array, Traversable, or ArrayAccess object of element options
     * - attributes: an array, Traversable, or ArrayAccess object of element
     *   attributes to assign
     * - elements: an array or Traversable object where each entry is an array
     *   or ArrayAccess object containing the keys:
     *   - flags: (optional) array of flags to pass to FieldsetInterface::add()
     *   - spec: the actual element specification, per {@link configureElement()}
     *
     * @param  FieldsetInterface             $fieldset
     * @param  array|Traversable|ArrayAccess $spec
     * @return FieldsetInterface
     * @throws Exception\DomainException
     */
    public function configureFieldset(FieldsetInterface $fieldset, $spec)
    {
        $spec     = $this->validateSpecification($spec, __METHOD__);
        $fieldset = $this->configureElement($fieldset, $spec);

        if (isset($spec['object'])) {
            $this->prepareAndInjectObject($spec['object'], $fieldset, __METHOD__);
        }

        if (isset($spec['hydrator'])) {
            $this->prepareAndInjectHydrator($spec['hydrator'], $fieldset, __METHOD__);
        }

        if (isset($spec['elements'])) {
            $this->prepareAndInjectElements($spec['elements'], $fieldset, __METHOD__);
        }

        if (isset($spec['fieldsets'])) {
            $this->prepareAndInjectFieldsets($spec['fieldsets'], $fieldset, __METHOD__);
        }

        $factory = isset($spec['factory']) ? $spec['factory'] : $this;
        $this->prepareAndInjectFactory($factory, $fieldset, __METHOD__);

        return $fieldset;
    }

    /**
     * Configure a form based on the provided specification
     *
     * Specification follows that of {@link configureFieldset()}, and adds the
     * following keys:
     *
     * - input_filter: input filter instance, named input filter class, or
     *   array specification for the input filter factory
     * - hydrator: hydrator instance or named hydrator class
     *
     * @param  FormInterface                  $form
     * @param  array|Traversable|ArrayAccess  $spec
     * @return FormInterface
     */
    public function configureForm(FormInterface $form, $spec)
    {
        $spec = $this->validateSpecification($spec, __METHOD__);
        $form = $this->configureFieldset($form, $spec);

        if (isset($spec['input_filter'])) {
            $this->prepareAndInjectInputFilter($spec['input_filter'], $form, __METHOD__);
        }

        if (isset($spec['validation_group'])) {
            $this->prepareAndInjectValidationGroup($spec['validation_group'], $form, __METHOD__);
        }

        return $form;
    }

    /**
     * Validate a provided specification
     *
     * Ensures we have an array, Traversable, or ArrayAccess object, and returns it.
     *
     * @param  array|Traversable|ArrayAccess $spec
     * @param  string $method Method invoking the validator
     * @return array|ArrayAccess
     * @throws Exception\InvalidArgumentException for invalid $spec
     */
    protected function validateSpecification($spec, $method)
    {
        if (is_array($spec)) {
            return $spec;
        }

        if ($spec instanceof Traversable) {
            $spec = ArrayUtils::iteratorToArray($spec);
            return $spec;
        }

        if (! $spec instanceof ArrayAccess) {
            throw new Exception\InvalidArgumentException(sprintf(
                '%s expects an array, or object implementing Traversable or ArrayAccess; received "%s"',
                $method,
                is_object($spec) ? get_class($spec) : gettype($spec)
            ));
        }

        return $spec;
    }

    /**
     * Takes a list of element specifications, creates the elements, and injects them into the provided fieldset
     *
     * @param  array|Traversable|ArrayAccess $elements
     * @param  FieldsetInterface $fieldset
     * @param  string $method Method invoking this one (for exception messages)
     * @return void
     */
    protected function prepareAndInjectElements($elements, FieldsetInterface $fieldset, $method)
    {
        $elements = $this->validateSpecification($elements, $method);

        foreach ($elements as $elementSpecification) {
            if (null === $elementSpecification) {
                continue;
            }

            $flags = isset($elementSpecification['flags']) ? $elementSpecification['flags'] : [];
            $spec  = isset($elementSpecification['spec']) ? $elementSpecification['spec'] : [];

            if (! isset($spec['type'])) {
                $spec['type'] = Element::class;
            }

            $element = $this->create($spec);
            $fieldset->add($element, $flags);
        }
    }

    /**
     * Takes a list of fieldset specifications, creates the fieldsets, and injects them into the master fieldset
     *
     * @param  array|Traversable|ArrayAccess $fieldsets
     * @param  FieldsetInterface $masterFieldset
     * @param  string $method Method invoking this one (for exception messages)
     * @return void
     */
    public function prepareAndInjectFieldsets($fieldsets, FieldsetInterface $masterFieldset, $method)
    {
        $fieldsets = $this->validateSpecification($fieldsets, $method);

        foreach ($fieldsets as $fieldsetSpecification) {
            $flags = isset($fieldsetSpecification['flags']) ? $fieldsetSpecification['flags'] : [];
            $spec  = isset($fieldsetSpecification['spec']) ? $fieldsetSpecification['spec'] : [];

            $fieldset = $this->createFieldset($spec);
            $masterFieldset->add($fieldset, $flags);
        }
    }

    /**
     * Prepare and inject an object
     *
     * Takes a string indicating a class name, instantiates the class
     * by that name, and injects the class instance as the bound object.
     *
     * @param  string            $objectName
     * @param  FieldsetInterface $fieldset
     * @param  string            $method
     * @throws Exception\DomainException
     * @return void
     */
    protected function prepareAndInjectObject($objectName, FieldsetInterface $fieldset, $method)
    {
        if (! is_string($objectName)) {
            throw new Exception\DomainException(sprintf(
                '%s expects string class name; received "%s"',
                $method,
                is_object($objectName) ? get_class($objectName) : gettype($objectName)
            ));
        }

        if (! class_exists($objectName)) {
            throw new Exception\DomainException(sprintf(
                '%s expects string class name to be a valid class name; received "%s"',
                $method,
                $objectName
            ));
        }

        $fieldset->setObject(new $objectName);
    }

    /**
     * Prepare and inject a named hydrator
     *
     * Takes a string indicating a hydrator class name (or a concrete instance), try first to instantiates the class
     * by pulling it from service manager, and injects the hydrator instance into the form.
     *
     * @param  string|array|Hydrator\HydratorInterface $hydratorOrName
     * @param  FieldsetInterface                       $fieldset
     * @param  string                                  $method
     * @return void
     * @throws Exception\DomainException If $hydratorOrName is not a string, does not resolve to a known class, or
     *                                   the class does not implement Hydrator\HydratorInterface
     */
    protected function prepareAndInjectHydrator($hydratorOrName, FieldsetInterface $fieldset, $method)
    {
        if ($hydratorOrName instanceof Hydrator\HydratorInterface) {
            $fieldset->setHydrator($hydratorOrName);
            return;
        }

        if (is_array($hydratorOrName)) {
            if (! isset($hydratorOrName['type'])) {
                throw new Exception\DomainException(sprintf(
                    '%s expects array specification to have a type value',
                    $method
                ));
            }
            $hydratorOptions = isset($hydratorOrName['options']) ? $hydratorOrName['options'] : [];
            $hydratorOrName = $hydratorOrName['type'];
        } else {
            $hydratorOptions = [];
        }

        if (is_string($hydratorOrName)) {
            $hydrator = $this->getFormElementManager()->getHydratorFromName($hydratorOrName);
        }

        if (! isset($hydrator) || ! $hydrator instanceof Hydrator\HydratorInterface) {
            throw new Exception\DomainException(sprintf(
                '%s expects a valid implementation of Laminas\Hydrator\HydratorInterface; received "%s"',
                $method,
                $hydratorOrName
            ));
        }

        if (! empty($hydratorOptions) && $hydrator instanceof Hydrator\HydratorOptionsInterface) {
            $hydrator->setOptions($hydratorOptions);
        }

        $fieldset->setHydrator($hydrator);
    }

    /**
     * Prepare and inject a named factory
     *
     * Takes a string indicating a factory class name (or a concrete instance), try first to instantiates the class
     * by pulling it from service manager, and injects the factory instance into the fieldset.
     *
     * @param  string|array|Factory      $factoryOrName
     * @param  FieldsetInterface         $fieldset
     * @param  string                    $method
     * @return void
     * @throws Exception\DomainException If $factoryOrName is not a string, does not resolve to a known class, or
     *                                   the class does not extend Form\Factory
     */
    protected function prepareAndInjectFactory($factoryOrName, FieldsetInterface $fieldset, $method)
    {
        if (is_array($factoryOrName)) {
            if (! isset($factoryOrName['type'])) {
                throw new Exception\DomainException(sprintf(
                    '%s expects array specification to have a type value',
                    $method
                ));
            }
            $factoryOrName = $factoryOrName['type'];
        }

        if (is_string($factoryOrName)) {
            $factoryOrName = $this->getFormElementManager()->getFactoryFromName($factoryOrName);
        }

        if (! $factoryOrName instanceof Factory) {
            throw new Exception\DomainException(sprintf(
                '%s expects a valid extension of Laminas\Form\Factory; received "%s"',
                $method,
                $factoryOrName
            ));
        }

        $fieldset->setFormFactory($factoryOrName);
    }

    /**
     * Prepare an input filter instance and inject in the provided form
     *
     * If the input filter specified is a string, assumes it is a class name,
     * and attempts to instantiate it. If the class does not exist, or does
     * not extend InputFilterInterface, an exception is raised.
     *
     * Otherwise, $spec is passed on to the attached InputFilter Factory
     * instance in order to create the input filter.
     *
     * @param  string|array|Traversable $spec
     * @param  FormInterface $form
     * @param  string $method
     * @return void
     * @throws Exception\DomainException for unknown InputFilter class or invalid InputFilter instance
     */
    protected function prepareAndInjectInputFilter($spec, FormInterface $form, $method)
    {
        if ($spec instanceof InputFilterInterface) {
            $form->setInputFilter($spec);
            return;
        }

        if (is_string($spec)) {
            if (! class_exists($spec)) {
                throw new Exception\DomainException(sprintf(
                    '%s expects string input filter names to be valid class names; received "%s"',
                    $method,
                    $spec
                ));
            }
            $filter = new $spec;
            if (! $filter instanceof InputFilterInterface) {
                throw new Exception\DomainException(sprintf(
                    '%s expects a valid implementation of Laminas\InputFilter\InputFilterInterface; received "%s"',
                    $method,
                    $spec
                ));
            }
            $form->setInputFilter($filter);
            return;
        }

        $factory = $this->getInputFilterFactory();
        $filter  = $factory->createInputFilter($spec);
        if (method_exists($filter, 'setFactory')) {
            $filter->setFactory($factory);
        }
        $form->setInputFilter($filter);
    }

    /**
     * Prepare a validation group and inject in the provided form
     *
     * Takes an array of elements names
     *
     * @param  string|array|Traversable $spec
     * @param  FormInterface $form
     * @param  string $method
     * @return void
     * @throws Exception\DomainException if validation group given is not an array
     */
    protected function prepareAndInjectValidationGroup($spec, FormInterface $form, $method)
    {
        if (! is_array($spec)) {
            if (! class_exists($spec)) {
                throw new Exception\DomainException(sprintf(
                    '%s expects an array for validation group; received "%s"',
                    $method,
                    $spec
                ));
            }
        }

        $form->setValidationGroup($spec);
    }

    /**
     * Try to pull hydrator from service manager, or instantiates it from its name
     *
     * @param  string $hydratorName
     * @return mixed
     * @throws Exception\DomainException
     */
    protected function getHydratorFromName($hydratorName)
    {
        trigger_error(sprintf(
            'Usage of %s is deprecated since v3.0.0; please use FormElementManager::getHydratorFromName() instead',
            __METHOD__
        ), E_USER_DEPRECATED);

        return $this->getFormElementManager()->getHydratorFromName($hydratorName);
    }

    /**
     * Try to pull factory from service manager, or instantiates it from its name
     *
     * @param  string $factoryName
     * @return mixed
     * @throws Exception\DomainException
     */
    protected function getFactoryFromName($factoryName)
    {
        trigger_error(sprintf(
            'Usage of %s is deprecated since v3.0.0; please use FormElementManager::getFactoryFromName() instead',
            __METHOD__
        ), E_USER_DEPRECATED);

        return $this->getFormElementManager()->getFactoryFromName($factoryName);
    }
}
