<?php

/*
 * This file is part of the Behat.
 * (c) Konstantin Kudryashov <ever.zet@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Behat\Behat\HelperContainer;

use Behat\Behat\HelperContainer\Exception\ServiceNotFoundException;
use Behat\Behat\HelperContainer\Exception\WrongServicesConfigurationException;
use Psr\Container\ContainerInterface as PsrContainerInterface;
use ReflectionClass;
use ReflectionMethod;

/**
 * Built-in service container.
 *
 * @author Konstantin Kudryashov <ever.zet@gmail.com>
 */
final class BuiltInServiceContainer implements PsrContainerInterface
{
    /**
     * @var array
     */
    private $schema;
    /**
     * @var array
     */
    private $instances;

    /**
     * Initialises container using provided service configuration.
     *
     * @param array $schema
     */
    public function __construct(array $schema)
    {
        $this->schema = $schema;
    }

    /**
     * {@inheritdoc}
     */
    public function has($id): bool
    {
        return array_key_exists($id, $this->schema);
    }

    /**
     * {@inheritdoc}
     */
    public function get($id)
    {
        if (!$this->has($id)) {
            throw new ServiceNotFoundException(
                sprintf('Service with id `%s` was not defined inside the `services` setting`.', $id),
                $id
            );
        }

        return $this->instances[$id] = $this->instances[$id] ?? $this->createInstance($id);
    }

    /**
     * Creates an instance of given service.
     *
     * @param string $id
     *
     * @return mixed
     */
    private function createInstance($id)
    {
        $schema = $this->getAndValidateServiceSchema($id);

        $reflection = new ReflectionClass($schema['class']);
        $arguments = $schema['arguments'];

        if ($factoryMethod = $this->getAndValidateFactoryMethod($reflection, $schema)) {
            return $factoryMethod->invokeArgs(null, $arguments);
        }

        return $reflection->newInstanceArgs($arguments);
    }

    /**
     * Gets and validates a service configuration for a service with given ID.
     *
     * @param string $id
     *
     * @throws WrongServicesConfigurationException
     *
     * @return array|string
     */
    private function getAndValidateServiceSchema($id)
    {
        $schema = $this->schema[$id];

        if (null === $schema) {
            $schema = array('class' => $id);
        }

        if (is_string($schema)) {
            $schema = array('class' => $schema);
        }

        $schema['class'] = $this->getAndValidateClass($id, $schema);
        $schema['arguments'] = $this->getAndValidateArguments($schema);

        return $schema;
    }

    /**
     * Gets and validates a class from schema.
     */
    private function getAndValidateClass(string $id, array $schema): string
    {
        if (!isset($schema['class'])) {
            $schema['class'] = $id;
        }

        return $schema['class'];
    }

    /**
     * Gets and validates arguments from schema.
     *
     * @param array $schema
     *
     * @return array
     */
    private function getAndValidateArguments(array $schema)
    {
        return isset($schema['arguments']) ? (array)$schema['arguments'] : array();
    }

    /**
     * Gets and validates a factory method.
     *
     * @param ReflectionClass $reflection
     * @param array           $schema
     *
     * @return null|ReflectionMethod
     */
    private function getAndValidateFactoryMethod(ReflectionClass $reflection, array $schema)
    {
        if (!isset($schema['factory_method'])) {
            return null;
        }

        $factoryMethod = $schema['factory_method'];
        $this->assertFactoryMethodExists($reflection, $factoryMethod);
        $method = $reflection->getMethod($factoryMethod);
        $this->assertFactoryMethodIsStatic($method);

        return $method;
    }

    /**
     * Checks if factory method exists.
     *
     * @param ReflectionClass $class
     * @param string          $methodName
     *
     * @throws WrongServicesConfigurationException
     */
    private function assertFactoryMethodExists(ReflectionClass $class, $methodName)
    {
        if (!$class->hasMethod($methodName)) {
            throw new WrongServicesConfigurationException(sprintf(
                'Factory method `%s::%s` does not exist.',
                $class->getName(),
                $methodName
            ));
        }
    }

    /**
     * Checks if factory method is static.
     *
     * @param ReflectionMethod $method
     *
     * @throws WrongServicesConfigurationException
     */
    private function assertFactoryMethodIsStatic(ReflectionMethod $method)
    {
        if (!$method->isStatic()) {
            throw new WrongServicesConfigurationException(sprintf(
                'Service factory methods must be static, but `%s::%s` is not.',
                $method->getDeclaringClass()->getName(),
                $method->getName()
            ));
        }
    }
}
