<?php

namespace XF;

use XF\SubContainer\AbstractSubContainer;

use function array_key_exists, is_object;

/**
 * Dependency injection container and object type factory.
 */
class Container implements \ArrayAccess
{
	protected $data = [];
	protected $cache = [];
	protected $cacheable = [];

	protected $factory = [];
	protected $factoryObjects = [];

	/**
	 * @var bool
	 */
	protected $locked = false;

	#[\ReturnTypeWillChange]
	public function offsetGet($key)
	{
		if (array_key_exists($key, $this->cache))
		{
			return $this->cache[$key];
		}

		if (array_key_exists($key, $this->data))
		{
			$value = $this->data[$key];

			$output = ($this->isInvokable($value) ? $value($this) : $value);
			if (!empty($this->cacheable[$key]))
			{
				$this->cache[$key] = $output;
			}

			return $output;
		}

		$namespace = $this->getNamespaceObject($key, $nsKey);
		if ($namespace)
		{
			return $namespace->offsetGet($nsKey);
		}

		throw new \InvalidArgumentException("Container key '$key' was not found");
	}

	public function isCached($key)
	{
		return array_key_exists($key, $this->cache);
	}

	public function decache($key)
	{
		unset($this->cache[$key]);
	}

	public function offsetSet($key, $value): void
	{
		$this->set($key, $value);
	}

	public function offsetExists($key): bool
	{
		if (array_key_exists($key, $this->data))
		{
			return true;
		}

		$namespace = $this->getNamespaceObject($key, $nsKey);
		if ($namespace)
		{
			return $namespace->offsetExists($nsKey);
		}

		return false;
	}

	public function offsetUnset($key): void
	{
		$this->assertNotLocked();

		unset($this->data[$key], $this->cache[$key], $this->cacheable[$key]);
	}

	public function __get($key)
	{
		return $this->offsetGet($key);
	}

	public function __set($key, $value)
	{
		$this->set($key, $value);
	}

	public function __isset($key)
	{
		return $this->offsetExists($key);
	}

	public function set($key, $value, $cache = null)
	{
		$this->assertNotLocked();

		$this->data[$key] = $value;

		if ($cache === null)
		{
			$cache = $this->isInvokable($value);
		}
		$this->cacheable[$key] = (bool) $cache;
		unset($this->cache[$key]);
	}

	/**
	 * @param string $value
	 * @param string $subKey Value of the key without the namespace
	 *
	 * @return AbstractSubContainer|null
	 */
	protected function getNamespaceObject($value, &$subKey = null)
	{
		$parts = explode('.', $value, 2);
		if (isset($parts[1]))
		{
			$namespace = $parts[0];
			if ($this->offsetExists($namespace))
			{
				$namespaceEntry = $this->offsetGet($namespace);
				if ($namespaceEntry instanceof AbstractSubContainer)
				{
					$subKey = $parts[1];
					return $namespaceEntry;
				}
			}
		}

		return null;
	}

	public function wrap($callable)
	{
		return function () use ($callable)
		{
			return $callable;
		};
	}

	public function extend($key, $callable)
	{
		$this->assertNotLocked();

		if (!$this->isInvokable($callable))
		{
			throw new \InvalidArgumentException("Extension must be invokable");
		}

		if (!array_key_exists($key, $this->data))
		{
			throw new \InvalidArgumentException("Container key '$key' was not found");
		}

		$value = $this->data[$key];
		$this->data[$key] = function (Container $container) use ($value, $callable)
		{
			$output = ($container->isInvokable($value) ? $value($container) : $value);
			return $callable($output, $container);
		};

		if (array_key_exists($key, $this->cache))
		{
			// this has already been cached, so we need to run this now so that the
			// next call picks this up
			$this->cache[$key] = $callable($this->cache[$key], $this);
		}
	}

	public function getOriginal($key)
	{
		if (array_key_exists($key, $this->data))
		{
			return $this->data[$key];
		}

		throw new \InvalidArgumentException("Container key '$key' was not found");
	}

	public function factory($type, $callable, $cacheable = true)
	{
		$this->assertNotLocked();

		if (!$this->isInvokable($callable))
		{
			throw new \InvalidArgumentException("Factory must be invokable");
		}

		$this->factory[$type] = [$callable, $cacheable];
	}

	public function extendFactory($type, $callable)
	{
		$this->assertNotLocked();

		if (!$this->isInvokable($callable))
		{
			throw new \InvalidArgumentException("Extension must be invokable");
		}

		if (!isset($this->factory[$type]))
		{
			throw new \InvalidArgumentException("Factory type '$type' was not found");
		}

		$original = $this->factory[$type][0];
		$this->factory[$type][0] = function ($class, array $params, Container $container) use ($original, $callable)
		{
			return $callable($class, $params, $container, $original);
		};
	}

	public function removeFactory($type)
	{
		$this->assertNotLocked();

		unset($this->factory[$type], $this->factoryObjects[$type]);
	}

	public function create($type, $key, array $params = [])
	{
		if (!isset($this->factory[$type]))
		{
			throw new \InvalidArgumentException("Factory type '$type' was not found");
		}

		[$callable, $cacheable] = $this->factory[$type];

		if ($cacheable && isset($this->factoryObjects[$type][$key]))
		{
			return $this->factoryObjects[$type][$key];
		}

		$object = $callable($key, $params, $this);
		if ($cacheable)
		{
			$this->factoryObjects[$type][$key] = $object;
		}

		return $object;
	}

	public function getInvokableFactory($type)
	{
		return function ($class, array $params = []) use ($type)
		{
			return $this->create($type, $class, $params);
		};
	}

	public function decacheFactory($type, $class = null)
	{
		if ($class)
		{
			unset($this->factoryObjects[$type][$class]);
		}
		else
		{
			unset($this->factoryObjects[$type]);
		}
	}

	public function createObject($class, array $params = [], $failSilently = false)
	{
		$params = array_values($params);

		if (!class_exists($class))
		{
			if ($failSilently)
			{
				return null;
			}
			throw new \LogicException("Class $class does not exist");
		}

		return new $class(...$params);
	}

	protected function isInvokable($value)
	{
		return is_object($value) && method_exists($value, '__invoke');
	}

	public function lock(): void
	{
		$this->locked = true;
	}

	public function unlock(): void
	{
		$this->locked = false;
	}

	public function isLocked(): bool
	{
		return $this->locked;
	}

	public function assertNotLocked(): void
	{
		if (!$this->isLocked())
		{
			return;
		}

		throw new \LogicException('Cannot modify container while locked');
	}

	public function __sleep()
	{
		throw new \LogicException('Instances of ' . self::class . ' cannot be serialized or unserialized');
	}

	public function __wakeup()
	{
		throw new \LogicException('Instances of ' . self::class . ' cannot be serialized or unserialized');
	}
}
