<?php

namespace NewfoldLabs\Container;

use ArrayAccess;
use Closure;
use Countable;
use Iterator;
use SplObjectStorage;

/**
 * Class Container
 *
 * @package WP_Forge\Container
 */
class Container implements ArrayAccess, ContainerInterface, Countable, Iterator {

	/**
	 * Entry storage.
	 *
	 * @var array
	 */
	protected $entries = [];

	/**
	 * Class instance storage.
	 *
	 * @var array
	 */
	protected $instances = [];

	/**
	 * Factory closure storage.
	 *
	 * @var SplObjectStorage
	 */
	protected $factories;

	/**
	 * Function storage.
	 *
	 * @var SplObjectStorage
	 */
	protected $functions;

	/**
	 * Service closure storage.
	 *
	 * @var SplObjectStorage
	 */
	protected $services;

	/**
	 * Internal pointer used for iteration.
	 *
	 * @var int
	 */
	protected $pointer = 0;

	/**
	 * Container constructor.
	 *
	 * @param array $entries Initial entries to store in the container.
	 */
	public function __construct( array $entries = [] ) {
		$this->reset();
		$this->entries = $entries;
	}

	/**
	 * Reset everything.
	 *
	 * @return $this
	 */
	public function reset() {
		$this->entries   = [];
		$this->instances = [];
		$this->factories = new SplObjectStorage();
		$this->functions = new SplObjectStorage();
		$this->services  = new SplObjectStorage();

		return $this;
	}

	/**
	 * Returns true if the container can return an entry for the given identifier.
	 * Returns false otherwise.
	 *
	 * `has($id)` returning true does not mean that `get($id)` will not throw an exception.
	 * It does however mean that `get($id)` will not throw a `NotFoundExceptionInterface`.
	 *
	 * @param string $id Identifier of the entry to look for.
	 *
	 * @return bool
	 */
	public function has( $id ) {
		return array_key_exists( $id, $this->entries );
	}

	/**
	 * Get a raw value.
	 *
	 * @param string $id Identifier of the entry to look for.
	 *
	 * @return mixed Entry.
	 *
	 * @throws NotFoundException
	 */
	public function raw( $id ) {
		if ( ! $this->has( $id ) ) {
			throw new NotFoundException( sprintf( 'No entry was found for "%s" identifier.', $id ) );
		}

		return $this->entries[ $id ];
	}

	/**
	 * Finds an entry of the container by its identifier and returns it.
	 *
	 * @param string $id Identifier of the entry to look for.
	 *
	 * @return mixed Entry.
	 *
	 * @throws NotFoundException  No entry was found for **this** identifier.
	 */
	public function get( $id ) {
		// Return class instance, if available.
		if ( isset( $this->instances[ $id ] ) ) {
			return $this->instances[ $id ];
		}

		// Get raw value.
		$value = $this->raw( $id );

		// If this is a factory, return a new instance.
		if ( $this->isFactory( $value ) ) {
			return $value( $this );
		}

		// If this is a service, return a single instance.
		if ( $this->isService( $value ) ) {
			$this->instances[ $id ] = $value( $this );

			return $this->instances[ $id ];
		}

		// If this is a computed value, compute and return the value.
		if ( $this->isComputed( $value ) ) {
			return $value( $this );
		}

		return $value;
	}

	/**
	 * Set an array value by ID.
	 *
	 * @param string $id    The entry identifier.
	 * @param mixed  $value The entry value.
	 *
	 * @return $this
	 */
	public function set( $id, $value ) {
		$this->entries[ $id ] = $value;

		return $this;
	}

	/**
	 * Delete an entry.
	 *
	 * @param string $id Identifier of the entry to look for.
	 *
	 * @return $this
	 *
	 * @throws NotFoundException
	 */
	public function delete( $id ) {
		if ( $this->has( $id ) ) {
			$value = $this->get( $id );
			if ( $this->isFactory( $value ) ) {
				$this->factories->detach( $value );
			} else if ( $this->isService( $value ) ) {
				$this->services->detach( $value );
			} else if ( $this->isComputed( $value ) ) {
				$this->functions->detach( $value );
			}
			unset( $this->entries[ $id ], $this->instances[ $id ] );
		}

		return $this;
	}

	/**
	 * Remove an instance.
	 *
	 * @param string $id Identifier of the instance to look for.
	 *
	 * @return $this
	 */
	public function deleteInstance( $id ) {
		unset( $this->instances[ $id ] );

		return $this;
	}

	/**
	 * Remove all instances.
	 *
	 * @return $this
	 */
	public function deleteAllInstances() {
		$this->instances = [];

		return $this;
	}

	/**
	 * Extend a factory or service by creating a closure that will manipulate the instantiated instance.
	 *
	 * @param string  $id
	 * @param Closure $closure
	 *
	 * @return Closure
	 *
	 * @throws ContainerException
	 * @throws NotFoundException
	 */
	public function extend( $id, Closure $closure ) {

		// Get the existing raw value
		$value = $this->raw( $id );

		// If the value isn't a factory or service, throw an exception.
		if ( ! $this->isService( $value ) && ! $this->isFactory( $value ) && ! $this->isComputed( $value ) ) {
			throw new ContainerException( sprintf( 'Identifier "%s" does not contain an object definition.', $id ) );
		}

		// Create a new closure that extends the existing one.
		$extended = function ( Container $container ) use ( $closure, $value ) {
			return $closure( $value( $container ), $container );
		};

		if ( $this->isFactory( $value ) ) {

			// Replace factory object.
			$this->factories->detach( $value );
			$this->factories->offsetSet( $extended );

		} else if ( $this->isService( $value ) ) {

			// Replace service object.
			$this->services->detach( $value );
			$this->services->offsetSet( $extended );

		} else if ( $this->isComputed( $value ) ) {

			// Replace function.
			$this->functions->detach( $value );
			$this->functions->offsetSet( $extended );

		}

		// Replace object in entries array.
		$this->entries[ $id ] = $extended;

		return $extended;
	}

	/**
	 * Marks a callable as a factory.
	 *
	 * @param Closure $closure A closure that returns a new class instance.
	 *
	 * @return Closure
	 */
	public function factory( Closure $closure ) {
		$this->factories->offsetSet( $closure );

		return $closure;
	}

	/**
	 * Checks if a value is a factory.
	 *
	 * @param mixed $value Value to check.
	 *
	 * @return bool
	 */
	public function isFactory( $value ) {
		return is_object( $value ) && isset( $this->factories[ $value ] );
	}

	/**
	 * Marks a callable as a computed value.
	 *
	 * @param Closure $closure A closure that returns a new instance (only called once).
	 *
	 * @return Closure
	 */
	public function computed( Closure $closure ) {
		$this->functions->offsetSet( $closure );

		return $closure;
	}

	/**
	 * Checks if a value is a service.
	 *
	 * @param mixed $value Value to check.
	 *
	 * @return bool
	 */
	public function isComputed( $value ) {
		return is_object( $value ) && isset( $this->functions[ $value ] );
	}

	/**
	 * Marks a callable as a service.
	 *
	 * @param Closure $closure A closure that returns a new instance (only called once).
	 *
	 * @return Closure
	 */
	public function service( Closure $closure ) {
		$this->services->offsetSet( $closure );

		return $closure;
	}

	/**
	 * Checks if a value is a service.
	 *
	 * @param mixed $value Value to check.
	 *
	 * @return bool
	 */
	public function isService( $value ) {
		return is_object( $value ) && isset( $this->services[ $value ] );
	}

	/**
	 * Get all array keys.
	 *
	 * @return array
	 */
	public function keys() {
		return array_keys( $this->entries );
	}

	/**
	 * Return the current element.
	 *
	 * Method implements Iterator.
	 *
	 * @return mixed Can return any type.
	 *
	 * @throws NotFoundException  No entry was found for **this** identifier.
	 */
	#[\ReturnTypeWillChange]
	public function current() {
		return $this->offsetGet( $this->key() );
	}

	/**
	 * Move forward to next element
	 *
	 * Method implements Iterator.
	 *
	 * @return void Any returned value is ignored.
	 */
	#[\ReturnTypeWillChange]
	public function next() {
		++ $this->pointer;
	}

	/**
	 * Return the key of the current element
	 *
	 * Method implements Iterator.
	 *
	 * @return string The entry identifier for the current entry.
	 */
	#[\ReturnTypeWillChange]
	public function key() {
		return $this->keys()[ $this->pointer ];
	}

	/**
	 * Checks if current position is valid
	 *
	 * Method implements Iterator.
	 *
	 * @return bool The return value will be cast to boolean and then evaluated.
	 */
	#[\ReturnTypeWillChange]
	public function valid() {
		return isset( $this->keys()[ $this->pointer ] );
	}

	/**
	 * Rewind the Iterator to the first element
	 *
	 * Method implements Iterator.
	 *
	 * @return void Any returned value is ignored.
	 */
	#[\ReturnTypeWillChange]
	public function rewind() {
		$this->pointer = 0;
	}

	/**
	 * Whether an offset exists.
	 *
	 * Method implements ArrayAccess.
	 *
	 * @param mixed $offset An offset to check for.
	 *
	 * @return bool True on success or false on failure.
	 */
	#[\ReturnTypeWillChange]
	public function offsetExists( $offset ) {
		return $this->has( $offset );
	}

	/**
	 * Offset to retrieve.
	 *
	 * Method implements ArrayAccess.
	 *
	 * @param mixed $offset The offset to retrieve.
	 *
	 * @return mixed Can return all value types.
	 *
	 * @throws NotFoundException  No entry was found for **this** identifier.
	 */
	#[\ReturnTypeWillChange]
	public function offsetGet( $offset ) {
		return $this->get( $offset );
	}

	/**
	 * Offset to set.
	 *
	 * Method implements ArrayAccess.
	 *
	 * @param mixed $offset The offset to assign the value to.
	 * @param mixed $value  The value to set.
	 *
	 * @return void
	 */
	#[\ReturnTypeWillChange]
	public function offsetSet( $offset, $value ) {
		$this->set( $offset, $value );
	}

	/**
	 * Offset to unset.
	 *
	 * Method implements ArrayAccess.
	 *
	 * @param mixed $offset The offset to unset.
	 *
	 * @return void
	 *
	 * @throws NotFoundException
	 */
	#[\ReturnTypeWillChange]
	public function offsetUnset( $offset ) {
		$this->delete( $offset );
	}

	/**
	 * Get the number of entries.
	 *
	 * Method implements Countable.
	 *
	 * @return int
	 */
	#[\ReturnTypeWillChange]
	public function count() {
		return count( $this->entries );
	}
}
