<?php

/**
 * This file is part of the Nette Framework (https://nette.org)
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
 */

declare(strict_types=1);

namespace Nette\DI;

use Nette;
use Nette\DI\Definitions\Definition;
use Nette\DI\Definitions\Reference;
use Nette\DI\Definitions\Statement;
use Nette\PhpGenerator\Helpers as PhpHelpers;
use Nette\Utils\Callback;
use Nette\Utils\Reflection;
use Nette\Utils\Strings;
use Nette\Utils\Validators;
use ReflectionClass;


/**
 * Services resolver
 * @internal
 */
class Resolver
{
	use Nette\SmartObject;

	/** @var ContainerBuilder */
	private $builder;

	/** @var Definition|null */
	private $currentService;

	/** @var string|null */
	private $currentServiceType;

	/** @var bool */
	private $currentServiceAllowed = false;

	/** @var \SplObjectStorage  circular reference detector */
	private $recursive;


	public function __construct(ContainerBuilder $builder)
	{
		$this->builder = $builder;
		$this->recursive = new \SplObjectStorage;
	}


	public function getContainerBuilder(): ContainerBuilder
	{
		return $this->builder;
	}


	public function resolveDefinition(Definition $def): void
	{
		if ($this->recursive->contains($def)) {
			$names = array_map(function ($item) { return $item->getName(); }, iterator_to_array($this->recursive));
			throw new ServiceCreationException(sprintf('Circular reference detected for services: %s.', implode(', ', $names)));
		}

		try {
			$this->recursive->attach($def);

			$def->resolveType($this);

			if (!$def->getType()) {
				throw new ServiceCreationException('Type of service is unknown.');
			}
		} catch (\Throwable $e) {
			throw $this->completeException($e, $def);

		} finally {
			$this->recursive->detach($def);
		}
	}


	public function resolveReferenceType(Reference $ref): ?string
	{
		if ($ref->isSelf()) {
			return $this->currentServiceType;
		} elseif ($ref->isType()) {
			return ltrim($ref->getValue(), '\\');
		}

		$def = $this->resolveReference($ref);
		if (!$def->getType()) {
			$this->resolveDefinition($def);
		}

		return $def->getType();
	}


	public function resolveEntityType(Statement $statement): ?string
	{
		$entity = $this->normalizeEntity($statement);

		if (is_array($entity)) {
			if ($entity[0] instanceof Reference || $entity[0] instanceof Statement) {
				$entity[0] = $this->resolveEntityType($entity[0] instanceof Statement ? $entity[0] : new Statement($entity[0]));
				if (!$entity[0]) {
					return null;
				}
			}

			try {
				/** @var \ReflectionMethod|\ReflectionFunction $reflection */
				$reflection = Callback::toReflection($entity[0] === '' ? $entity[1] : $entity);
				$refClass = $reflection instanceof \ReflectionMethod
					? $reflection->getDeclaringClass()
					: null;
			} catch (\ReflectionException $e) {
				$refClass = $reflection = null;
			}

			if (isset($e) || ($refClass && (!$reflection->isPublic()
				|| ($refClass->isTrait() && !$reflection->isStatic())
			))) {
				throw new ServiceCreationException(sprintf('Method %s() is not callable.', Callback::toString($entity)), 0, $e ?? null);
			}

			$this->addDependency($reflection);

			$type = Nette\Utils\Type::fromReflection($reflection) ?? Helpers::getReturnTypeAnnotation($reflection);
			if ($type && !in_array((string) $type, ['object', 'mixed'], true)) {
				return Helpers::ensureClassType($type, sprintf('return type of %s()', Callback::toString($entity)));
			}

			return null;

		} elseif ($entity instanceof Reference) { // alias or factory
			return $this->resolveReferenceType($entity);

		} elseif (is_string($entity)) { // class
			if (!class_exists($entity)) {
				throw new ServiceCreationException(sprintf(
					interface_exists($entity)
						? "Interface %s can not be used as 'factory', did you mean 'implement'?"
						: "Class '%s' not found.",
					$entity
				));
			}

			return $entity;
		}

		return null;
	}


	public function completeDefinition(Definition $def): void
	{
		$this->currentService = in_array($def, $this->builder->getDefinitions(), true)
			? $def
			: null;
		$this->currentServiceType = $def->getType();
		$this->currentServiceAllowed = false;

		try {
			$def->complete($this);

			$this->addDependency(new \ReflectionClass($def->getType()));

		} catch (\Throwable $e) {
			throw $this->completeException($e, $def);

		} finally {
			$this->currentService = $this->currentServiceType = null;
		}
	}


	public function completeStatement(Statement $statement, bool $currentServiceAllowed = false): Statement
	{
		$this->currentServiceAllowed = $currentServiceAllowed;
		$entity = $this->normalizeEntity($statement);
		$arguments = $this->convertReferences($statement->arguments);
		$getter = function (string $type, bool $single) {
			return $single
				? $this->getByType($type)
				: array_values(array_filter($this->builder->findAutowired($type), function ($obj) { return $obj !== $this->currentService; }));
		};

		switch (true) {
			case is_string($entity) && Strings::contains($entity, '?'): // PHP literal
				break;

			case $entity === 'not':
				if (count($arguments) > 1) {
					throw new ServiceCreationException(sprintf(
						'Function %s() expects at most 1 parameter, %s given.',
						$entity,
						count($arguments)
					));
				}

				$entity = ['', '!'];
				break;

			case $entity === 'bool':
			case $entity === 'int':
			case $entity === 'float':
			case $entity === 'string':
				if (count($arguments) > 1) {
					throw new ServiceCreationException(sprintf(
						'Function %s() expects at most 1 parameter, %s given.',
						$entity,
						count($arguments)
					));
				}

				$arguments = [$arguments[0], $entity];
				$entity = [Helpers::class, 'convertType'];
				break;

			case is_string($entity): // create class
				if (!class_exists($entity)) {
					throw new ServiceCreationException(sprintf("Class '%s' not found.", $entity));
				} elseif ((new ReflectionClass($entity))->isAbstract()) {
					throw new ServiceCreationException(sprintf('Class %s is abstract.', $entity));
				} elseif (($rm = (new ReflectionClass($entity))->getConstructor()) !== null && !$rm->isPublic()) {
					throw new ServiceCreationException(sprintf('Class %s has %s constructor.', $entity, $rm->isProtected() ? 'protected' : 'private'));
				} elseif ($constructor = (new ReflectionClass($entity))->getConstructor()) {
					$arguments = self::autowireArguments($constructor, $arguments, $getter);
					$this->addDependency($constructor);
				} elseif ($arguments) {
					throw new ServiceCreationException(sprintf(
						'Unable to pass arguments, class %s has no constructor.',
						$entity
					));
				}

				break;

			case $entity instanceof Reference:
				$entity = [new Reference(ContainerBuilder::THIS_CONTAINER), Container::getMethodName($entity->getValue())];
				break;

			case is_array($entity):
				if (!preg_match('#^\$?(\\\\?' . PhpHelpers::PHP_IDENT . ')+(\[\])?$#D', $entity[1])) {
					throw new ServiceCreationException(sprintf(
						"Expected function, method or property name, '%s' given.",
						$entity[1]
					));
				}

				switch (true) {
					case $entity[0] === '': // function call
						if (!Nette\Utils\Arrays::isList($arguments)) {
							throw new ServiceCreationException(sprintf(
								'Unable to pass specified arguments to %s.',
								$entity[0]
							));
						} elseif (!function_exists($entity[1])) {
							throw new ServiceCreationException(sprintf("Function %s doesn't exist.", $entity[1]));
						}

						$rf = new \ReflectionFunction($entity[1]);
						$arguments = self::autowireArguments($rf, $arguments, $getter);
						$this->addDependency($rf);
						break;

					case $entity[0] instanceof Statement:
						$entity[0] = $this->completeStatement($entity[0], $this->currentServiceAllowed);
						// break omitted

					case is_string($entity[0]): // static method call
					case $entity[0] instanceof Reference:
						if ($entity[1][0] === '$') { // property getter, setter or appender
							Validators::assert($arguments, 'list:0..1', "setup arguments for '" . Callback::toString($entity) . "'");
							if (!$arguments && substr($entity[1], -2) === '[]') {
								throw new ServiceCreationException(sprintf('Missing argument for %s.', $entity[1]));
							}
						} elseif (
							$type = $entity[0] instanceof Reference
								? $this->resolveReferenceType($entity[0])
								: $this->resolveEntityType($entity[0] instanceof Statement ? $entity[0] : new Statement($entity[0]))
						) {
							$rc = new ReflectionClass($type);
							if ($rc->hasMethod($entity[1])) {
								$rm = $rc->getMethod($entity[1]);
								if (!$rm->isPublic()) {
									throw new ServiceCreationException(sprintf('%s::%s() is not callable.', $type, $entity[1]));
								}

								$arguments = self::autowireArguments($rm, $arguments, $getter);
								$this->addDependency($rm);

							} elseif (!Nette\Utils\Arrays::isList($arguments)) {
								throw new ServiceCreationException(sprintf('Unable to pass specified arguments to %s::%s().', $type, $entity[1]));
							}
						}
				}
		}

		try {
			$arguments = $this->completeArguments($arguments);
		} catch (ServiceCreationException $e) {
			if (!strpos($e->getMessage(), ' (used in')) {
				$e->setMessage($e->getMessage() . " (used in {$this->entityToString($entity)})");
			}

			throw $e;
		}

		return new Statement($entity, $arguments);
	}


	public function completeArguments(array $arguments): array
	{
		array_walk_recursive($arguments, function (&$val): void {
			if ($val instanceof Statement) {
				$entity = $val->getEntity();
				if ($entity === 'typed' || $entity === 'tagged') {
					$services = [];
					$current = $this->currentService
						? $this->currentService->getName()
						: null;
					foreach ($val->arguments as $argument) {
						foreach ($entity === 'tagged' ? $this->builder->findByTag($argument) : $this->builder->findAutowired($argument) as $name => $foo) {
							if ($name !== $current) {
								$services[] = new Reference($name);
							}
						}
					}

					$val = $this->completeArguments($services);
				} else {
					$val = $this->completeStatement($val, $this->currentServiceAllowed);
				}
			} elseif ($val instanceof Definition || $val instanceof Reference) {
				$val = $this->normalizeEntity(new Statement($val));
			}
		});
		return $arguments;
	}


	/** @return string|array|Reference  literal, Class, Reference, [Class, member], [, globalFunc], [Reference, member], [Statement, member] */
	private function normalizeEntity(Statement $statement)
	{
		$entity = $statement->getEntity();
		if (is_array($entity)) {
			$item = &$entity[0];
		} else {
			$item = &$entity;
		}

		if ($item instanceof Definition) {
			$name = current(array_keys($this->builder->getDefinitions(), $item, true));
			if ($name === false) {
				throw new ServiceCreationException(sprintf("Service '%s' not found in definitions.", $item->getName()));
			}

			$item = new Reference($name);
		}

		if ($item instanceof Reference) {
			$item = $this->normalizeReference($item);
		}

		return $entity;
	}


	/**
	 * Normalizes reference to 'self' or named reference (or leaves it typed if it is not possible during resolving) and checks existence of service.
	 */
	public function normalizeReference(Reference $ref): Reference
	{
		$service = $ref->getValue();
		if ($ref->isSelf()) {
			return $ref;
		} elseif ($ref->isName()) {
			if (!$this->builder->hasDefinition($service)) {
				throw new ServiceCreationException(sprintf("Reference to missing service '%s'.", $service));
			}

			return $this->currentService && $service === $this->currentService->getName()
				? new Reference(Reference::SELF)
				: $ref;
		}

		try {
			return $this->getByType($service);
		} catch (NotAllowedDuringResolvingException $e) {
			return new Reference($service);
		}
	}


	public function resolveReference(Reference $ref): Definition
	{
		return $ref->isSelf()
			? $this->currentService
			: $this->builder->getDefinition($ref->getValue());
	}


	/**
	 * Returns named reference to service resolved by type (or 'self' reference for local-autowiring).
	 * @throws ServiceCreationException when multiple found
	 * @throws MissingServiceException when not found
	 */
	public function getByType(string $type): Reference
	{
		if (
			$this->currentService
			&& $this->currentServiceAllowed
			&& is_a($this->currentServiceType, $type, true)
		) {
			return new Reference(Reference::SELF);
		}

		$name = $this->builder->getByType($type, true);
		if (
			!$this->currentServiceAllowed
			&& $this->currentService === $this->builder->getDefinition($name)
		) {
			throw new MissingServiceException;
		}

		return new Reference($name);
	}


	/**
	 * Adds item to the list of dependencies.
	 * @param  \ReflectionClass|\ReflectionFunctionAbstract|string  $dep
	 * @return static
	 */
	public function addDependency($dep)
	{
		$this->builder->addDependency($dep);
		return $this;
	}


	private function completeException(\Throwable $e, Definition $def): ServiceCreationException
	{
		if ($e instanceof ServiceCreationException && Strings::startsWith($e->getMessage(), "Service '")) {
			return $e;
		}

		$name = $def->getName();
		$type = $def->getType();
		if ($name && !ctype_digit($name)) {
			$message = "Service '$name'" . ($type ? " (type of $type)" : '') . ': ';
		} elseif ($type) {
			$message = "Service of type $type: ";
		} elseif ($def instanceof Definitions\ServiceDefinition && $def->getEntity()) {
			$message = 'Service (' . $this->entityToString($def->getEntity()) . '): ';
		} else {
			$message = '';
		}

		$message .= $type
			? str_replace("$type::", preg_replace('~.*\\\\~', '', $type) . '::', $e->getMessage())
			: $e->getMessage();

		return $e instanceof ServiceCreationException
			? $e->setMessage($message)
			: new ServiceCreationException($message, 0, $e);
	}


	private function entityToString($entity): string
	{
		$referenceToText = function (Reference $ref): string {
			return $ref->isSelf() && $this->currentService
				? '@' . $this->currentService->getName()
				: '@' . $ref->getValue();
		};
		if (is_string($entity)) {
			return $entity . '::__construct()';
		} elseif ($entity instanceof Reference) {
			$entity = $referenceToText($entity);
		} elseif (is_array($entity)) {
			if (strpos($entity[1], '$') === false) {
				$entity[1] .= '()';
			}

			if ($entity[0] instanceof Reference) {
				$entity[0] = $referenceToText($entity[0]);
			} elseif (!is_string($entity[0])) {
				return $entity[1];
			}

			return implode('::', $entity);
		}

		return (string) $entity;
	}


	private function convertReferences(array $arguments): array
	{
		array_walk_recursive($arguments, function (&$val): void {
			if (is_string($val) && strlen($val) > 1 && $val[0] === '@' && $val[1] !== '@') {
				$pair = explode('::', substr($val, 1), 2);
				if (!isset($pair[1])) { // @service
					$val = new Reference($pair[0]);
				} elseif (preg_match('#^[A-Z][A-Z0-9_]*$#D', $pair[1], $m)) { // @service::CONSTANT
					$val = ContainerBuilder::literal($this->resolveReferenceType(new Reference($pair[0])) . '::' . $pair[1]);
				} else { // @service::property
					$val = new Statement([new Reference($pair[0]), '$' . $pair[1]]);
				}
			} elseif (is_string($val) && substr($val, 0, 2) === '@@') { // escaped text @@
				$val = substr($val, 1);
			}
		});
		return $arguments;
	}


	/**
	 * Add missing arguments using autowiring.
	 * @param  (callable(string $type, bool $single): (object|object[]|null))  $getter
	 * @throws ServiceCreationException
	 */
	public static function autowireArguments(
		\ReflectionFunctionAbstract $method,
		array $arguments,
		callable $getter
	): array {
		$optCount = 0;
		$useName = false;
		$num = -1;
		$res = [];

		foreach ($method->getParameters() as $num => $param) {
			$paramName = $param->name;

			if ($param->isVariadic()) {
				if (array_key_exists($paramName, $arguments)) {
					if (!is_array($arguments[$paramName])) {
						throw new ServiceCreationException(sprintf(
							'Parameter %s must be array, %s given.',
							Reflection::toString($param),
							gettype($arguments[$paramName])
						));
					}

					$variadics = $arguments[$paramName];
					unset($arguments[$paramName]);
				} else {
					$variadics = array_merge($arguments);
					$arguments = [];
				}

				if ($useName) {
					$res[$paramName] = $variadics;
				} else {
					$res = array_merge($res, $variadics);
				}

				$optCount = 0;
				break;

			} elseif (array_key_exists($paramName, $arguments)) {
				$res[$useName ? $paramName : $num] = $arguments[$paramName];
				unset($arguments[$paramName], $arguments[$num]);

			} elseif (array_key_exists($num, $arguments)) {
				$res[$useName ? $paramName : $num] = $arguments[$num];
				unset($arguments[$num]);

			} elseif (($aw = self::autowireArgument($param, $getter)) !== null) {
				$res[$useName ? $paramName : $num] = $aw;

			} elseif (PHP_VERSION_ID >= 80000) {
				if ($param->isOptional()) {
					$useName = true;
				} else {
					$res[$num] = null;
				}

			} else {
				$res[$num] = $param->isDefaultValueAvailable()
					? Reflection::getParameterDefaultValue($param)
					: null;
			}

			if (PHP_VERSION_ID < 80000) {
				$optCount = $param->isOptional() && $res[$num] === ($param->isDefaultValueAvailable() ? Reflection::getParameterDefaultValue($param) : null)
					? $optCount + 1
					: 0;
			}
		}

		// extra parameters
		while (!$useName && !$optCount && array_key_exists(++$num, $arguments)) {
			$res[$num] = $arguments[$num];
			unset($arguments[$num]);
		}

		if ($arguments) {
			throw new ServiceCreationException(sprintf(
				'Unable to pass specified arguments to %s.',
				Reflection::toString($method)
			));
		} elseif ($optCount) {
			$res = array_slice($res, 0, -$optCount);
		}

		return $res;
	}


	/**
	 * Resolves missing argument using autowiring.
	 * @param  (callable(string $type, bool $single): (object|object[]|null))  $getter
	 * @throws ServiceCreationException
	 * @return mixed
	 */
	private static function autowireArgument(\ReflectionParameter $parameter, callable $getter)
	{
		$method = $parameter->getDeclaringFunction();
		$desc = Reflection::toString($parameter);
		$type = Nette\Utils\Type::fromReflection($parameter);

		if ($parameter->getType() instanceof \ReflectionIntersectionType) {
			throw new ServiceCreationException(sprintf(
				'Parameter %s has intersection type, so its value must be specified.',
				$desc
			));

		} elseif ($type && $type->isClass()) {
			$class = $type->getSingleName();
			try {
				$res = $getter($class, true);
			} catch (MissingServiceException $e) {
				$res = null;
			} catch (ServiceCreationException $e) {
				throw new ServiceCreationException("{$e->getMessage()} (required by $desc)", 0, $e);
			}

			if ($res !== null || $parameter->allowsNull()) {
				return $res;
			} elseif (class_exists($class) || interface_exists($class)) {
				throw new ServiceCreationException(sprintf(
					'Service of type %s required by %s not found. Did you add it to configuration file?',
					$class,
					$desc
				));
			} else {
				throw new ServiceCreationException(sprintf(
					"Class '%s' required by %s not found. Check the parameter type and 'use' statements.",
					$class,
					$desc
				));
			}
		} elseif (
			$method instanceof \ReflectionMethod
			&& $type && $type->getSingleName() === 'array'
			&& preg_match('#@param[ \t]+([\w\\\\]+)\[\][ \t]+\$' . $parameter->name . '#', (string) $method->getDocComment(), $m)
			&& ($itemType = Reflection::expandClassName($m[1], $method->getDeclaringClass()))
			&& (class_exists($itemType) || interface_exists($itemType))
		) {
			return $getter($itemType, false);

		} elseif (
			($type && $parameter->allowsNull())
			|| $parameter->isOptional()
			|| $parameter->isDefaultValueAvailable()
		) {
			// !optional + defaultAvailable, !optional + !defaultAvailable since 8.1.0 = func($a = null, $b)
			// optional + !defaultAvailable, optional + defaultAvailable since 8.0.0 = i.e. Exception::__construct, mysqli::mysqli, ...
			// optional + !defaultAvailable = variadics
			// in other cases the optional and defaultAvailable are identical
			return null;

		} else {
			throw new ServiceCreationException(sprintf(
				'Parameter %s has %s, so its value must be specified.',
				$desc,
				$type && $type->isUnion() ? 'union type and no default value' : 'no class type or default value'
			));
		}
	}
}
