<?php

namespace Planaday\Utility\DependencyInjection;

use Exception;
use Planaday\Utility\DataGrouping\Collection;
use Planaday\Utility\Debug\Log\Logger;
use Planaday\Utility\Debug\Log\Logging;
use Planaday\Utility\DependencyInjection\Attributes\DefaultImplementation;
use Planaday\Utility\DependencyInjection\Attributes\InjectedProperty;
use Planaday\Utility\DependencyInjection\Attributes\InjectionMethod;
use Planaday\Utility\DependencyInjection\Attributes\Scoped;
use Planaday\Utility\DependencyInjection\Attributes\Singleton;
use Planaday\Utility\DependencyInjection\Attributes\Transient;
use ReflectionClass;
use ReflectionException;
use ReflectionIntersectionType;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionType;
use ReflectionUnionType;

#[Singleton]
class Container {
	use Logging;

	protected Scope $globalScope;
	protected ImplementationRegister $implementations;
	protected ConstructorRegister $constructors;
	/** @var Collection<callable():void> */
	protected Collection $injectionCallbacks;

	/** @var class-string[] */
	protected array $currentlyFetching = [];

	const MAX_DEPTH = 100;

	public function __construct(Logger $logger = new Logger()) {
		$this->globalScope  = new Scope();
		$this->globalScope->scopeId = 'global';
		$this->loggingScopeId = 'global';
		$this->constructors = new ConstructorRegister();
		$this->implementations = new ImplementationRegister();
		$this->injectionCallbacks = new Collection();
		$this->logger = $logger;
		$this->setSingleton(Logger::class, $this->logger);
		$this->setSingleton(self::class, $this);
		$this->logInfo('Created new registry');
		$this->globalScope->setLogger($this->logger, $this->globalScope);
	}

	/**
	 * @template C
	 * @template I of C
	 *
	 * @param class-string<C> $componentClass
	 * @param I|null $component
	 *
	 * @return $this
	 */
	public function setSingleton(string $componentClass, $component): static{
		$this->globalScope->set($componentClass, $component);
		return $this;
	}

	/**
	 * @template C
	 * @param class-string<C> $componentClass
	 *
	 * @return C
	 * @throws InjectionImpossibleException
	 */
	public function getInstanceSingleton(string $componentClass){
		/** @var C $instance */
		$instance = $this->getSingleton($componentClass);

		$this->doQueuedInjections();

		return $instance;
	}

	/**
	 * @template C
	 * @param class-string<C> $componentClass
	 *
	 * @return C
	 * @throws InjectionImpossibleException
	 */
	protected function getSingleton(string $componentClass){
		$componentClass = $this->getImplementationClass($componentClass);
		/** @var C $instance */
		$instance = $this->globalScope->get($componentClass);
		if ($instance !== null) return $instance;

		/** @var C $instance */
		$instance = $this->createInstance($componentClass, $this->globalScope);
		$this->globalScope->set($componentClass, $instance);
		return $instance;
	}

	/**
	 * @template C
	 * @param class-string<C> $componentClass
	 *
	 * @return C
	 * @throws InjectionImpossibleException
	 */
	public function getInstanceScoped(Scope $scope, string $componentClass){
		/** @var C $instance */
		$instance = $this->getScoped($scope, $componentClass);

		$this->doQueuedInjections();

		return $instance;
	}

	public function getNewScope(): Scope {
		$scope = new Scope();
		$scope->setLogger($this->logger, $scope);
		return $scope;
	}

	/**
	 * @template C
	 * @param class-string<C> $componentClass
	 *
	 * @return C
	 * @throws InjectionImpossibleException
	 */
	protected function getScoped(Scope $scope, string $componentClass){
		$componentClass = $this->getImplementationClass($componentClass);

		/** @var C $instance */
		$instance = $scope->get($componentClass);
		if ($instance !== null) return $instance;

		$instance = $this->createInstance($componentClass, $scope);
		$scope->set($componentClass, $instance);
		return $instance;
	}

	/**
	 * @template C
	 * @param class-string<C> $componentClass
	 *
	 * @return C
	 * @throws InjectionImpossibleException
	 */
	public function getInstanceTransient(string $componentClass, ?Scope $scope = null){
		/** @var C $instance */
		$instance = $this->getTransient($componentClass, $scope ?? $this->globalScope);

		$this->doQueuedInjections();

		return $instance;
	}

	/**
	 * @template C
	 * @param class-string<C> $componentClass
	 *
	 * @return C
	 * @throws InjectionImpossibleException
	 */
	protected function getTransient(string $componentClass, Scope $scope){
		$componentClass = $this->getImplementationClass($componentClass);

		/** @var C $instance */
		$instance = $this->createInstance($componentClass, $scope);
		return $instance;
	}

	/**
	 * @template C
	 * @param class-string<C> $componentClass
	 *
	 * @return C
	 * @throws InjectionImpossibleException
	 */
	protected function createInstance(string $componentClass, Scope $scope){
		/** @var callable():C $constructor */
		$constructor = $this->constructors->get($componentClass);
		if ($constructor === null){
			$constructor = $this->createConstructor($componentClass);
			$this->constructors->set($componentClass, $constructor);
		}

		try{
			/** @var C $instance */
			$instance = $constructor($scope);
			return $instance;
		} catch ( Exception $e ) {
			$this->logError("$componentClass constructor threw exception: " . $e->getMessage());
			throw new InjectionImpossibleException("$componentClass constructor threw exception: " . $e->getMessage(), $e->getCode(), $e);
		}
	}

	/**
	 * @template C
	 * @param class-string<C> $componentClass
	 *
	 * @return class-string<C>
	 */
	public function getImplementationClass(string $componentClass): string {
		$implementation = $this->implementations->get($componentClass);
		if ($implementation === null) return $componentClass;
		return $this->getImplementationClass($implementation);
	}

	/**
	 * @template C
	 * @param class-string<C> $componentClass
	 *
	 * @return callable(?Scope $scope):C
	 * @throws InjectionImpossibleException
	 */
	protected function createConstructor(string $componentClass): callable{
		try {
			$reflectionClass = new ReflectionClass($componentClass);
		} catch (ReflectionException) {
			$message = "Cannot create constructor: $componentClass does not exist";
			$this->logError($message);
			throw new InjectionImpossibleException($message);
		}

		if (!$reflectionClass->isInstantiable()){
			if (($attribute = $reflectionClass->getAttributes(DefaultImplementation::class)[0] ?? null) !== null){
				/** @var DefaultImplementation $attributeInstance */
				$attributeInstance = $attribute->newInstance();
				$implementationClass = $attributeInstance->implementationClass;
				return function (Scope $scope) use ($implementationClass) {
					return $this->getInstanceInternal($implementationClass, $scope);
				};
			}

			$message = "Cannot create constructor: $componentClass is not instantiable";
			$this->logError($message);
			throw new InjectionImpossibleException($message);
		}

		$reflectionConstructor = $reflectionClass->getConstructor();

		$phpVersion = explode('.', phpversion());
		if ($reflectionConstructor === null){
			$constructor = function (Scope $scope) use ($reflectionClass, $componentClass) {
				$this->conductChecks($componentClass);
				$this->currentlyFetching[] = $componentClass;
				try{
					$instance = $reflectionClass->newInstance();
					$this->injectionCallbacks->append(function () use ($instance, $scope) {
						$this->doInjections($instance, $scope);
					});
				} finally {
					array_pop($this->currentlyFetching);
				}
				$this->logDebug("Created new instance of {$reflectionClass->getName()}");
				return $instance;
			};

			if ($phpVersion[0] > 8 || ($phpVersion[0] == 8 && $phpVersion[1] >= 4)) {
				return function (Scope $scope) use ($reflectionClass, $constructor) {
					/** @noinspection PhpElementIsNotAvailableInCurrentPhpVersionInspection */
					return $reflectionClass->newLazyProxy(function () use ($scope, $constructor) {
						$doInjectionsAfter = $this->injectionCallbacks->count() === 0;
						$instance = $constructor($scope);
						if ($doInjectionsAfter) $this->doQueuedInjections();

						return $instance;
					});
				};
			} else {
				return $constructor;
			}
		}

		if (!$reflectionConstructor->isPublic()){
			$message = "Cannot create constructor: $componentClass has non-public constructor";
			$this->logError($message);
			return function () use ($message){
				throw new InjectionImpossibleException($message);
			};
		}

		$parameters = $this->getMethodParametersAsCallables($reflectionConstructor);

		$constructor = function (Scope $scope) use ($reflectionClass, $parameters, $componentClass){
			$this->conductChecks($componentClass);
			$this->currentlyFetching[] = $componentClass;
			try{
				$loadedParameters = [];
				foreach ($parameters as $parameter){
					$loadedParameters[] = $parameter($scope);
				}
				$instance = $reflectionClass->newInstance(...$loadedParameters);

				$this->injectionCallbacks->append(function () use ($instance, $scope) {
					$this->doInjections($instance, $scope);
				});
			} finally {
				array_pop($this->currentlyFetching);
			}

			return $instance;
		};

		if ($phpVersion[0] > 8 || ($phpVersion[0] == 8 && $phpVersion[1] >= 4)) {
			return function (Scope $scope) use ($reflectionClass, $constructor) {
				/** @noinspection PhpElementIsNotAvailableInCurrentPhpVersionInspection */
				return $reflectionClass->newLazyProxy(function () use ($scope, $constructor) {
					$doInjectionsAfter = $this->injectionCallbacks->count() === 0;
					$instance = $constructor($scope);
					if ($doInjectionsAfter) $this->doQueuedInjections();

					return $instance;
				});
			};
		}

		return $constructor;
	}

	/**
	 * @param ReflectionMethod $method
	 *
	 * @return iterable<callable(Scope $scope):mixed>
	 * @throws InjectionImpossibleException
	 */
	protected function getMethodParametersAsCallables(ReflectionMethod $method): iterable{
		$callables = [];
		$parameters = $method->getParameters();
		foreach ($parameters as $parameter){
			$paramType = $this->getUsableType($parameter->getType());
			$defaultValue = null;
			if ($parameter->isOptional()) {
				try {
					$defaultValue = $parameter->getDefaultValue();
				} catch ( ReflectionException ) {}
			}
			$allowsNull = $parameter->allowsNull();

			if ($paramType === null){
				if ($parameter->isOptional() && isset($defaultValue)){
					$callables[] = function () use ($defaultValue){
						return $defaultValue;
					};
					continue;
				} elseif ($allowsNull){
					$callables[] = function () {return null;};
					continue;
				}

				$message = "Could not prepare parameters: required parameter with invalid or missing type";
				$this->logError($message);
				throw new InjectionImpossibleException($message);
			}

			if (count($parameter->getAttributes(Transient::class)) === 1){
				$callables[] = function (Scope $scope) use ($paramType, $defaultValue, $allowsNull) {
					try {
						return $this->getTransient($paramType, $scope);
					} catch (Exception $e){
						if ($defaultValue !== null || $allowsNull) return $defaultValue;
						throw $e;
					}
				};
				continue;
			}

			if (count($parameter->getAttributes(Scoped::class)) === 1){
				$callables[] = function (Scope $scope) use ($paramType, $defaultValue, $allowsNull) {
					try {
						return $this->getScoped($scope, $paramType);
					} catch (Exception $e){
						if ($defaultValue !== null || $allowsNull) return $defaultValue;
						throw $e;
					}
				};
				continue;
			}

			$callables[] = function (Scope $scope) use ($paramType, $defaultValue, $allowsNull) {
				try {
					return $this->getInstanceInternal($paramType, $scope);
				} catch (Exception $e){
					if ($defaultValue !== null || $allowsNull) return $defaultValue;
					throw $e;
				}
			};
		}

		return $callables;
	}

	/**
	 * @template C
	 * @template I of C
	 * @param class-string<C> $componentClass
	 * @param class-string<I> $implementation
	 *
	 * @return $this
	 */
	public function setImplementation(string $componentClass, string $implementation): static {
		$this->implementations->set($componentClass, $implementation);
		$this->logInfo("Set $componentClass to $implementation");
		return $this;
	}

	/**
	 * @template C
	 * @param class-string<C> $class
	 *
	 * @return C
	 * @throws InjectionImpossibleException
	 */
	public function getInstance(string $class, ?Scope $scope = null){
		/** @var C $instance */
		$instance = $this->getInstanceInternal($class, $scope ?? $this->globalScope);

		$this->doQueuedInjections();

		return $instance;
	}

	/**
	 * @template C
	 * @param class-string<C> $class
	 *
	 * @return C
	 * @throws InjectionImpossibleException
	 */
	protected function getInstanceInternal(string $class, Scope $scope){
		try {
			$class = $this->getImplementationClass($class);
			$reflection = new ReflectionClass($class);
		} catch (ReflectionException){
			$message = "Get instance impossible: $class is not a valid class";
			$this->logError($message);
			throw new InjectionImpossibleException($message);
		}

		if (count($reflection->getAttributes(Transient::class)) === 1){
			return $this->getTransient($class, $scope);
		}

		if (count($reflection->getAttributes(Scoped::class)) === 1){
			return $this->getScoped($scope, $class);
		}

		return $this->getSingleton($class);
	}

	protected function doQueuedInjections(): void {
		$i = 0;

		while ($i < $this->injectionCallbacks->count()) {
			$this->injectionCallbacks[$i]();
			$i++;
		}

		$this->injectionCallbacks = new Collection();
	}

	public function doInjections(object $instance, ?Scope $scope = null): void {
		$scope = $scope ?? $this->globalScope;
		$reflectionClass = new ReflectionClass($instance);

		foreach ($reflectionClass->getProperties() as $property) {
			if (count($property->getAttributes(InjectedProperty::class)) === 0) continue;

			$propertyType = $this->getUsableType($property->getType());

			if ($propertyType === null) {
				$this->logError("Property {$property->getName()} of {$reflectionClass->getName()} has no valid type hint");
				continue;
			}

			if (count($property->getAttributes(Transient::class)) === 1){
				try {
					$propInstance = $this->getTransient($propertyType, $scope);
					$property->setValue($instance, $propInstance);
					continue;
				} catch (Exception){
					$this->logError("Property {$property->getName()} of {$reflectionClass->getName()} threw exception while trying to inject transient");
					continue;
				}
			}

			if (count($property->getAttributes(Scoped::class)) === 1){
				try {
					$propInstance = $this->getScoped($scope, $propertyType);
					$property->setValue($instance, $propInstance);
					continue;
				} catch (Exception){
					$this->logError("Property {$property->getName()} of {$reflectionClass->getName()} threw exception while trying to inject scoped");
					continue;
				}
			}

			try {
				$propInstance = $this->getInstanceInternal($propertyType, $scope);
				$property->setValue($instance, $propInstance);
				continue;
			} catch (Exception){
				$this->logError("Property {$property->getName()} of {$reflectionClass->getName()} threw exception while trying to inject");
				continue;
			}
		}

		foreach ($reflectionClass->getMethods() as $method) {
			$this->doMethod($instance, $method, $scope);
		}
	}

	protected function doMethod(Object $instance, ReflectionMethod $method, Scope $scope): void{
		if (count($method->getAttributes(InjectionMethod::class)) === 0) return;

		try{
			$arguments = $this->getMethodParametersAsCallables($method);
		} catch (InjectionImpossibleException){
			$this->logError("Failed to prepare parameters for {$method->getName()}");
			return;
		}

		try {
			$method->invokeArgs($instance, [...(function () use ($arguments, $scope) {foreach ($arguments as $argument) yield $argument($scope);})()]);
		} catch (ReflectionException) {
			$this->logError("Failed to invoke function {$method->getName()}");
		}
	}

	/**
	 * @param ReflectionType|null $type
	 *
	 * @return class-string|null
	 */
	protected function getUsableType(ReflectionType | null $type): ?string{
		if ($type === null) {
			$this->logWarning("No type hint detected, cannot be injected");
			return null;
		}

		if ($type instanceof ReflectionIntersectionType) {
			$this->logWarning("Intersection type hint detected, cannot be injected");
			return null;
		}

		if ($type instanceof ReflectionUnionType ) {
			foreach ($type->getTypes() as $subType) {
				if ($subType instanceof ReflectionNamedType && !$subType->isBuiltin())
					return $subType->getName();
			}
			$this->logWarning("Union type hint detected but no supported injections found, cannot be injected");
			return null;
		}

		if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) return $type->getName();

		$this->logWarning("Type hint detected but no supported injections found, cannot be injected");
		return null;
	}

	/**
	 * @param $componentClass
	 *
	 * @return void
	 * @throws InjectionImpossibleException
	 */
	protected function conductChecks($componentClass): void {
		if (in_array($componentClass, $this->currentlyFetching)) {
			$this->logError("Circular dependency detected in $componentClass", ['currentlyConstructing' => $this->currentlyFetching]);
			throw new InjectionImpossibleException("Circular dependency detected in " . $componentClass);
		}

		if ( count($this->currentlyFetching) + 1 > self::MAX_DEPTH){
			$this->logError("Depth exceeded in $componentClass", ['maxDepth' => self::MAX_DEPTH, 'currentlyConstructing' => $this->currentlyFetching]);
			throw new InjectionImpossibleException("dependency injection depth exceeded in " . $componentClass . " starting at " . $this->currentlyFetching[0]);
		}
	}

	public function destroy():void{
		$this->globalScope->destroy();
		unset($this->globalScope);
		unset($this->constructors);
		unset($this->implementations);
		unset($this->currentlyFetching);
		unset($this->injectionCallbacks);
	}
}
