<?php
/**
 * Part of the Joomla Framework DI Package
 *
 * @copyright  Copyright (C) 2013 - 2018 Open Source Matters, Inc. All rights reserved.
 * @license    GNU General Public License version 2 or later; see LICENSE
 */

namespace Joomla\DI;

use Joomla\DI\Exception\DependencyResolutionException;
use Joomla\DI\Exception\KeyNotFoundException;
use Joomla\DI\Exception\ProtectedKeyException;
use Psr\Container\ContainerInterface;

/**
 * The Container class.
 *
 * @since  1.0
 */
class Container implements ContainerInterface
{
	/**
	 * Holds the key aliases.
	 *
	 * Format:
	 * 'alias' => 'key'
	 *
	 * @var    array
	 * @since  1.0
	 */
	protected $aliases = [];

	/**
	 * Holds the resources.
	 *
	 * @var    ContainerResource[]
	 * @since  2.0.0
	 */
	protected $resources = [];

	/**
	 * Parent for hierarchical containers.
	 *
	 * In fact, this can be any PSR-11 compatible container, which gets decorated by this
	 *
	 * @var    Container|ContainerInterface|null
	 * @since  1.0
	 */
	protected $parent;

	/**
	 * Holds the service tag mapping.
	 *
	 * @var    array
	 * @since  1.5.0
	 */
	protected $tags = [];

	/**
	 * Constructor for the DI Container
	 *
	 * @param   ContainerInterface|null  $parent  Parent for hierarchical containers.
	 *
	 * @since   1.0
	 */
	public function __construct(?ContainerInterface $parent = null)
	{
		$this->parent = $parent;
	}

	/**
	 * Retrieve a resource
	 *
	 * @param   string  $resourceName  Name of the resource to get.
	 *
	 * @return  mixed  The requested resource
	 *
	 * @since   1.0
	 * @throws  KeyNotFoundException
	 */
	public function get($resourceName)
	{
		$key = $this->resolveAlias($resourceName);

		if (!isset($this->resources[$key]))
		{
			if ($this->parent instanceof ContainerInterface && $this->parent->has($key))
			{
				return $this->parent->get($key);
			}

			throw new KeyNotFoundException(sprintf("Resource '%s' has not been registered with the container.", $resourceName));
		}

		return $this->resources[$key]->getInstance();
	}

	/**
	 * Check if specified resource exists.
	 *
	 * @param   string  $resourceName  Name of the resource to check.
	 *
	 * @return  boolean  true if key is defined, false otherwise
	 *
	 * @since   1.5.0
	 */
	public function has($resourceName)
	{
		$key = $this->resolveAlias($resourceName);

		if (!isset($this->resources[$key]))
		{
			if ($this->parent instanceof ContainerInterface)
			{
				return $this->parent->has($key);
			}

			return false;
		}

		return true;
	}

	/**
	 * Method to check if specified dataStore key exists.
	 *
	 * @param   string  $key  Name of the dataStore key to check.
	 *
	 * @return  boolean  True for success
	 *
	 * @since   1.0
	 * @deprecated  3.0  Use ContainerInterface::has() instead
	 */
	public function exists($key)
	{
		trigger_deprecation(
			'joomla/di',
			'1.5.0',
			'%s() is deprecated and will be removed in 3.0, use %s::has() instead.',
			__METHOD__,
			ContainerInterface::class
		);

		return $this->has($key);
	}

	/**
	 * Create an alias for a given key for easy access.
	 *
	 * @param   string  $alias  The alias name
	 * @param   string  $key    The key to alias
	 *
	 * @return  $this
	 *
	 * @since   1.0
	 */
	public function alias($alias, $key)
	{
		$this->aliases[$alias] = $key;

		return $this;
	}

	/**
	 * Resolve a resource name.
	 *
	 * If the resource name is an alias, the corresponding key is returned.
	 * If the resource name is not an alias, the resource name is returned unchanged.
	 *
	 * @param   string  $resourceName  The key to search for.
	 *
	 * @return  string
	 *
	 * @since   1.0
	 */
	protected function resolveAlias($resourceName)
	{
		return $this->aliases[$resourceName] ?? $resourceName;
	}

	/**
	 * Check whether a resource is shared
	 *
	 * @param   string  $resourceName  Name of the resource to check.
	 *
	 * @return  boolean
	 *
	 * @since   2.0.0
	 */
	public function isShared(string $resourceName): bool
	{
		return $this->hasFlag($resourceName, 'isShared', true);
	}

	/**
	 * Check whether a resource is protected
	 *
	 * @param   string  $resourceName  Name of the resource to check.
	 *
	 * @return  boolean
	 *
	 * @since   2.0.0
	 */
	public function isProtected(string $resourceName): bool
	{
		return $this->hasFlag($resourceName, 'isProtected', true);
	}

	/**
	 * Check whether a flag (i.e., one of 'shared' or 'protected') is set
	 *
	 * @param   string   $resourceName  Name of the resource to check.
	 * @param   string   $method        Method to delegate to
	 * @param   boolean  $default       Default return value
	 *
	 * @return  boolean
	 *
	 * @since   2.0.0
	 * @throws  KeyNotFoundException
	 */
	private function hasFlag(string $resourceName, string $method, bool $default = true): bool
	{
		$key = $this->resolveAlias($resourceName);

		if (isset($this->resources[$key]))
		{
			return \call_user_func([$this->resources[$key], $method]);
		}

		if ($this->parent instanceof self)
		{
			return \call_user_func([$this->parent, $method], $key);
		}

		if ($this->parent instanceof ContainerInterface && $this->parent->has($key))
		{
			// We don't know if the parent supports the 'shared' or 'protected' concept, so we assume the default
			return $default;
		}

		throw new KeyNotFoundException(sprintf("Resource '%s' has not been registered with the container.", $resourceName));
	}

	/**
	 * Assign a tag to services.
	 *
	 * @param   string  $tag   The tag name
	 * @param   array   $keys  The service keys to tag
	 *
	 * @return  $this
	 *
	 * @since   1.5.0
	 */
	public function tag($tag, array $keys)
	{
		foreach ($keys as $key)
		{
			$resolvedKey = $this->resolveAlias($key);

			if (!isset($this->tags[$tag]))
			{
				$this->tags[$tag] = [];
			}

			$this->tags[$tag][] = $resolvedKey;
		}

		// Prune duplicates
		$this->tags[$tag] = array_unique($this->tags[$tag]);

		return $this;
	}

	/**
	 * Fetch all services registered to the given tag.
	 *
	 * @param   string  $tag  The tag name
	 *
	 * @return  array  The resolved services for the given tag
	 *
	 * @since   1.5.0
	 */
	public function getTagged($tag)
	{
		$services = [];

		if (isset($this->tags[$tag]))
		{
			foreach ($this->tags[$tag] as $service)
			{
				$services[] = $this->get($service);
			}
		}

		return $services;
	}

	/**
	 * Build an object of the requested class
	 *
	 * Creates an instance of the class specified by $resourceName with all dependencies injected.
	 * If the dependencies cannot be completely resolved, a DependencyResolutionException is thrown.
	 *
	 * @param   string   $resourceName  The class name to build.
	 * @param   boolean  $shared        True to create a shared resource.
	 *
	 * @return  object|false  Instance of class specified by $resourceName with all dependencies injected.
	 *                        Returns an object if the class exists and false otherwise
	 *
	 * @since   1.0
	 * @throws  DependencyResolutionException if the object could not be built (due to missing information)
	 */
	public function buildObject($resourceName, $shared = false)
	{
		static $buildStack = [];

		$key = $this->resolveAlias($resourceName);

		if (\in_array($key, $buildStack, true))
		{
			$buildStack = [];

			throw new DependencyResolutionException(sprintf('Cannot resolve circular dependency for "%s"', $key));
		}

		$buildStack[] = $key;

		if ($this->has($key))
		{
			$resource = $this->get($key);
			array_pop($buildStack);

			return $resource;
		}

		try
		{
			$reflection = new \ReflectionClass($key);
		}
		catch (\ReflectionException $e)
		{
			array_pop($buildStack);

			return false;
		}

		if (!$reflection->isInstantiable())
		{
			$buildStack = [];

			if ($reflection->isInterface())
			{
				throw new DependencyResolutionException(
					sprintf('There is no service for "%s" defined, cannot autowire a class service for an interface.', $key)
				);
			}

			if ($reflection->isAbstract())
			{
				throw new DependencyResolutionException(
					sprintf('There is no service for "%s" defined, cannot autowire an abstract class.', $key)
				);
			}

			throw new DependencyResolutionException(sprintf('"%s" cannot be instantiated.', $key));
		}

		$constructor = $reflection->getConstructor();

		// If there are no parameters, just return a new object.
		if ($constructor === null)
		{
			// There is no constructor, just return a new object.
			$callback = function () use ($key)
			{
				return new $key;
			};
		}
		else
		{
			$newInstanceArgs = $this->getMethodArgs($constructor);

			$callback = function () use ($reflection, $newInstanceArgs)
			{
				return $reflection->newInstanceArgs($newInstanceArgs);
			};
		}

		$this->set($key, $callback, $shared);

		$resource = $this->get($key);
		array_pop($buildStack);

		return $resource;
	}

	/**
	 * Convenience method for building a shared object.
	 *
	 * @param   string  $resourceName  The class name to build.
	 *
	 * @return  object|false  Instance of class specified by $resourceName with all dependencies injected.
	 *                        Returns an object if the class exists and false otherwise
	 *
	 * @since   1.0
	 */
	public function buildSharedObject($resourceName)
	{
		return $this->buildObject($resourceName, true);
	}

	/**
	 * Create a child Container with a new property scope that has the ability to access the parent scope when resolving.
	 *
	 * @return  Container  A new container with the current as a parent
	 *
	 * @since   1.0
	 */
	public function createChild()
	{
		return new static($this);
	}

	/**
	 * Extend a defined service Closure by wrapping the existing one with a new callable function.
	 *
	 * This works very similar to a decorator pattern.  Note that this only works on service Closures
	 * that have been defined in the current container, not parent containers.
	 *
	 * @param   string    $resourceName  The unique identifier for the Closure or property.
	 * @param   callable  $callable      A callable to wrap the original service Closure.
	 *
	 * @return  void
	 *
	 * @since   1.0
	 * @throws  KeyNotFoundException
	 */
	public function extend($resourceName, callable $callable)
	{
		$key      = $this->resolveAlias($resourceName);
		$resource = $this->getResource($key, true);

		$closure = function ($c) use ($callable, $resource)
		{
			return $callable($resource->getInstance(), $c);
		};

		$this->set($key, $closure, $resource->isShared());
	}

	/**
	 * Build an array of method arguments.
	 *
	 * @param   \ReflectionMethod  $method  Method for which to build the argument array.
	 *
	 * @return  array  Array of arguments to pass to the method.
	 *
	 * @since   1.0
	 * @throws  DependencyResolutionException
	 */
	private function getMethodArgs(\ReflectionMethod $method): array
	{
		$methodArgs = [];

		foreach ($method->getParameters() as $param)
		{
			// Check for a typehinted dependency
			if ($param->hasType())
			{
				$dependency = $param->getType();

				// Don't support PHP 8 union types
				if ($dependency instanceof \ReflectionUnionType)
				{
					// If this is a nullable parameter, then don't error out
					if ($param->allowsNull())
					{
						$methodArgs[] = null;

						continue;
					}

					throw new DependencyResolutionException(
						sprintf(
							'Could not resolve the parameter "$%s" of "%s::%s()": Union typehints are not supported.',
							$param->name,
							$method->class,
							$method->name
						)
					);
				}

				// Check for a class, if it doesn't have one then it is a scalar type, which we cannot handle if a mandatory argument
				if ($dependency->isBuiltin())
				{
					// If the param is optional, then fall through to the optional param handling later in this method
					if (!$param->isOptional())
					{
						$message = 'Could not resolve the parameter "$%s" of "%s::%s()":';
						$message .= ' Scalar parameters cannot be autowired and the parameter does not have a default value.';

						throw new DependencyResolutionException(
							sprintf(
								$message,
								$param->name,
								$method->class,
								$method->name
							)
						);
					}
				}
				else
				{
					$dependencyClassName = $dependency->getName();

					// Check that class or interface exists
					if (!interface_exists($dependencyClassName) && !class_exists($dependencyClassName))
					{
						// If this is a nullable parameter, then don't error out
						if ($param->allowsNull())
						{
							$methodArgs[] = null;

							continue;
						}

						throw new DependencyResolutionException(
							sprintf(
								'Could not resolve the parameter "$%s" of "%s::%s()": The "%s" class does not exist.',
								$param->name,
								$method->class,
								$method->name,
								$dependencyClassName
							)
						);
					}

					// If the dependency class name is registered with this container or a parent, use it.
					if ($this->getResource($dependencyClassName) !== null)
					{
						$depObject = $this->get($dependencyClassName);
					}
					else
					{
						try
						{
							$depObject = $this->buildObject($dependencyClassName);
						}
						catch (DependencyResolutionException $exception)
						{
							// If this is a nullable parameter, then don't error out
							if ($param->allowsNull())
							{
								$methodArgs[] = null;

								continue;
							}

							$message = 'Could not resolve the parameter "$%s" of "%s::%s()":';
							$message .= ' No service for "%s" exists and the dependency could not be autowired.';

							throw new DependencyResolutionException(
								sprintf(
									$message,
									$param->name,
									$method->class,
									$method->name,
									$dependencyClassName
								),
								0,
								$exception
							);
						}
					}

					if ($depObject instanceof $dependencyClassName)
					{
						$methodArgs[] = $depObject;

						continue;
					}
				}
			}

			// If there is a default parameter and it can be read, use it.
			if ($param->isOptional() && $param->isDefaultValueAvailable())
			{
				try
				{
					$methodArgs[] = $param->getDefaultValue();

					continue;
				}
				catch (\ReflectionException $exception)
				{
					throw new DependencyResolutionException(
						sprintf(
							'Could not resolve the parameter "$%s" of "%s::%s()": Unable to read the default parameter value.',
							$param->name,
							$method->class,
							$method->name
						),
						0,
						$exception
					);
				}
			}

			// If an untyped variadic argument, skip it
			if (!$param->hasType() && $param->isVariadic())
			{
				continue;
			}

			// At this point the argument cannot be resolved, most likely cause is an untyped required argument
			throw new DependencyResolutionException(
				sprintf(
					'Could not resolve the parameter "$%s" of "%s::%s()": The argument is untyped and has no default value.',
					$param->name,
					$method->class,
					$method->name
				)
			);
		}

		return $methodArgs;
	}

	/**
	 * Set a resource to the container. If the value is null the resource is removed.
	 *
	 * @param   string   $key        Name of resources key to set.
	 * @param   mixed    $value      Callable function to run or string to retrive when requesting the specified $key.
	 * @param   boolean  $shared     True to create and store a shared instance.
	 * @param   boolean  $protected  True to protect this item from being overwritten. Useful for services.
	 *
	 * @return  $this
	 *
	 * @since   1.0
	 * @throws  ProtectedKeyException  Thrown if the provided key is already set and is protected.
	 */
	public function set($key, $value, $shared = false, $protected = false)
	{
		$key = $this->resolveAlias($key);

		$hasKey = $this->has($key);

		if ($hasKey && $this->isProtected($key))
		{
			throw new ProtectedKeyException(sprintf("Key %s is protected and can't be overwritten.", $key));
		}

		if ($value === null && $hasKey)
		{
			unset($this->resources[$key]);

			return $this;
		}

		$mode = $shared ? ContainerResource::SHARE : ContainerResource::NO_SHARE;
		$mode |= $protected ? ContainerResource::PROTECT : ContainerResource::NO_PROTECT;

		$this->resources[$key] = new ContainerResource($this, $value, $mode);

		return $this;
	}

	/**
	 * Shortcut method for creating protected keys.
	 *
	 * @param   string   $key     Name of dataStore key to set.
	 * @param   mixed    $value   Callable function to run or string to retrive when requesting the specified $key.
	 * @param   boolean  $shared  True to create and store a shared instance.
	 *
	 * @return  $this
	 *
	 * @since   1.0
	 */
	public function protect($key, $value, $shared = false)
	{
		return $this->set($key, $value, $shared, true);
	}

	/**
	 * Shortcut method for creating shared keys.
	 *
	 * @param   string   $key        Name of dataStore key to set.
	 * @param   mixed    $value      Callable function to run or string to retrive when requesting the specified $key.
	 * @param   boolean  $protected  True to protect this item from being overwritten. Useful for services.
	 *
	 * @return  $this
	 *
	 * @since   1.0
	 */
	public function share($key, $value, $protected = false)
	{
		return $this->set($key, $value, true, $protected);
	}

	/**
	 * Get the raw data assigned to a key.
	 *
	 * @param   string   $key   The key for which to get the stored item.
	 * @param   boolean  $bail  Throw an exception, if the key is not found
	 *
	 * @return  ContainerResource|null  The resource if present, or null if instructed to not bail
	 *
	 * @since   2.0.0
	 * @throws  KeyNotFoundException
	 */
	public function getResource(string $key, bool $bail = false): ?ContainerResource
	{
		if (isset($this->resources[$key]))
		{
			return $this->resources[$key];
		}

		if ($this->parent instanceof self)
		{
			return $this->parent->getResource($key);
		}

		if ($this->parent instanceof ContainerInterface && $this->parent->has($key))
		{
			return new ContainerResource($this, $this->parent->get($key), ContainerResource::SHARE | ContainerResource::PROTECT);
		}

		if ($bail)
		{
			throw new KeyNotFoundException(sprintf('Key %s has not been registered with the container.', $key));
		}

		return null;
	}

	/**
	 * Method to force the container to return a new instance of the results of the callback for requested $key.
	 *
	 * @param   string  $key  Name of the resources key to get.
	 *
	 * @return  mixed   Results of running the callback for the specified key.
	 *
	 * @since   1.0
	 */
	public function getNewInstance($key)
	{
		$key = $this->resolveAlias($key);

		$this->getResource($key, true)->reset();

		return $this->get($key);
	}

	/**
	 * Register a service provider to the container.
	 *
	 * @param   ServiceProviderInterface  $provider  The service provider to register.
	 *
	 * @return  $this
	 *
	 * @since   1.0
	 */
	public function registerServiceProvider(ServiceProviderInterface $provider)
	{
		$provider->register($this);

		return $this;
	}

	/**
	 * Retrieve the keys for services assigned to this container.
	 *
	 * @return  array
	 *
	 * @since   1.5.0
	 */
	public function getKeys()
	{
		return array_unique(array_merge(array_keys($this->aliases), array_keys($this->resources)));
	}
}
