<?php
/**
 * Minimal Dependency Injection Container
 *
 * @package ZenoChatbot\Framework\Foundation
 */

namespace ZenoChatbot\Framework\Foundation;

use ReflectionClass;
use ReflectionParameter;
use Closure;
use Exception;

// Prevent direct access
if (!defined('ABSPATH')) {
    exit;
}

/**
 * Lightweight container with auto-wiring and dependency injection
 */
class Container
{
    /**
     * Container bindings
     *
     * @var array
     */
    protected $bindings = [];

    /**
     * Container instances (singletons)
     *
     * @var array
     */
    protected $instances = [];

    /**
     * Container aliases
     *
     * @var array
     */
    protected $aliases = [];

    /**
     * After resolving callbacks
     *
     * @var array
     */
    protected $afterResolvingCallbacks = [];

    /**
     * Stack of currently resolving abstractions to detect circular dependencies
     *
     * @var array
     */
    protected $resolvingStack = [];

    /**
     * Bind a service to the container
     *
     * @param string $abstract
     * @param mixed $concrete
     * @param bool $shared
     * @return void
     */
    public function bind($abstract, $concrete = null, $shared = false)
    {
        // Remove existing instances if rebinding
        unset($this->instances[$abstract], $this->aliases[$abstract]);

        // If no concrete type was given, we will simply set the concrete type to the
        // abstract type. After that, the concrete type to be registered as shared
        // without being forced to state their classes in both of the parameters.
        if (is_null($concrete)) {
            $concrete = $abstract;
        }

        // If the factory is not a Closure, it means it is just a class name which is
        // bound into this container to the abstract type and we will just wrap it
        // up inside its own Closure to give us more convenience when extending.
        if (!$concrete instanceof Closure) {
            $concrete = $this->getClosure($abstract, $concrete);
        }

        $this->bindings[$abstract] = compact('concrete', 'shared');
    }

    /**
     * Register a shared binding in the container
     *
     * @param string $abstract
     * @param mixed $concrete
     * @return void
     */
    public function singleton($abstract, $concrete = null)
    {
        $this->bind($abstract, $concrete, true);
    }

    /**
     * Register an existing instance as shared in the container
     *
     * @param string $abstract
     * @param mixed $instance
     * @return mixed
     */
    public function instance($abstract, $instance)
    {
        $this->removeAbstractAlias($abstract);

        $this->instances[$abstract] = $instance;

        return $instance;
    }

    /**
     * Alias a type to a different name
     *
     * @param string $abstract
     * @param string $alias
     * @return void
     */
    public function alias($abstract, $alias)
    {
        $this->aliases[$alias] = $abstract;
    }

    /**
     * Resolve the given type from the container
     *
     * @param string $abstract
     * @param array $parameters
     * @return mixed
     * @throws Exception
     */
    public function make($abstract, array $parameters = [])
    {
        return $this->resolve($abstract, $parameters);
    }

    /**
     * Resolve the given type from the container
     *
     * @param string $abstract
     * @param array $parameters
     * @return mixed
     * @throws Exception
     */
    protected function resolve($abstract, $parameters = [])
    {
        $abstract = $this->getAlias($abstract);

        // If an instance of the type is currently being managed as a singleton we'll
        // just return an existing instance instead of instantiating new instances
        // so the developer can keep using the same objects instance every time.
        if (isset($this->instances[$abstract]) && empty($parameters)) {
            return $this->instances[$abstract];
        }

        $concrete = $this->getConcrete($abstract);

        // We're ready to instantiate an instance of the concrete type registered for
        // the binding. This will instantiate the types, as well as resolve any of
        // its "nested" dependencies recursively until all have gotten resolved.
        if ($this->isBuildable($concrete, $abstract)) {
            $object = $this->build($concrete, $parameters);
        } else {
            $object = $this->make($concrete, $parameters);
        }

        // If the requested type is registered as a singleton we'll want to cache off
        // the instances in "memory" so we can return it later without creating an
        // entirely new instance of an object on each subsequent request for it.
        if ($this->isShared($abstract) && empty($parameters)) {
            $this->instances[$abstract] = $object;
        }

        $this->fireAfterResolvingCallbacks($abstract, $object);

        return $object;
    }

    /**
     * Get the concrete type for a given abstract
     *
     * @param string $abstract
     * @return mixed
     */
    protected function getConcrete($abstract)
    {
        if (isset($this->bindings[$abstract])) {
            return $this->bindings[$abstract]['concrete'];
        }

        return $abstract;
    }

    /**
     * Instantiate a concrete instance of the given type
     *
     * @param string $concrete
     * @param array $parameters
     * @return mixed
     * @throws Exception
     */
    public function build($concrete, array $parameters = [])
    {
        // If the concrete type is actually a Closure, we will just execute it and
        // hand back the results of the functions, which allows functions to be
        // used as resolvers for more fine-tuned resolution of these objects.
        if ($concrete instanceof Closure) {
            return $concrete($this, $parameters);
        }

        // Prevent circular dependencies
        if (in_array($concrete, $this->resolvingStack, true)) {
            $cycle = implode(' -> ', array_merge($this->resolvingStack, [$concrete]));
            throw new Exception("Circular dependency detected: {$cycle}");
        }

        $this->resolvingStack[] = $concrete;

        try {
            $reflector = new ReflectionClass($concrete);
        } catch (\ReflectionException $e) {
            throw new Exception("Target class [{$concrete}] does not exist.", 0, $e);
        }

        // If the type is not instantiable, the developer is attempting to resolve
        // an abstract type such as an Interface or Abstract Class and there is
        // no binding registered for the abstractions so we need to bail out.
        if (!$reflector->isInstantiable()) {
            throw new Exception("Target [{$concrete}] is not instantiable.");
        }

        $constructor = $reflector->getConstructor();

        // If there are no constructors, that means there are no dependencies then
        // we can just resolve the instances of the objects right away, without
        // resolving any other types or dependencies out of these containers.
        if (is_null($constructor)) {
            array_pop($this->resolvingStack);
            return new $concrete;
        }

        $dependencies = $constructor->getParameters();

        // Once we have all the constructor's parameters we can create each of the
        // dependency instances and then use the reflection instances to make a
        // new instance of this class, injecting the created dependencies in.
        try {
            $instances = $this->resolveDependencies($dependencies, $parameters);
            array_pop($this->resolvingStack);
            return $reflector->newInstanceArgs($instances);
        } catch (Exception $e) {
            array_pop($this->resolvingStack);
            throw $e;
        }
    }

    /**
     * Resolve all of the dependencies from the ReflectionParameters
     *
     * @param array $dependencies
     * @param array $parameters
     * @return array
     * @throws Exception
     */
    protected function resolveDependencies(array $dependencies, array $parameters = [])
    {
        $results = [];

        foreach ($dependencies as $dependency) {
            // If the dependency has a corresponding override in the parameters array, we will use
            // that instead as the value. Otherwise, we will continue with this run of resolutions
            // and let the container determine what to inject based on the dependency's type-hint.
            if (array_key_exists($dependency->name, $parameters)) {
                $results[] = $parameters[$dependency->name];
                continue;
            }

            // If the class is null, it means the dependency is a string or some other
            // primitive type which we can not resolve since it is not a class and
            // we will just bomb out with an error since we have no-where to go.
            $result = is_null($this->getParameterClassName($dependency))
                ? $this->resolvePrimitive($dependency)
                : $this->resolveClass($dependency);

            $results[] = $result;
        }

        return $results;
    }

    /**
     * Resolve a non-class hinted primitive dependency
     *
     * @param ReflectionParameter $parameter
     * @return mixed
     * @throws Exception
     */
    protected function resolvePrimitive(ReflectionParameter $parameter)
    {
        if ($parameter->isDefaultValueAvailable()) {
            return $parameter->getDefaultValue();
        }

        throw new Exception("Unresolvable dependency resolving [{$parameter}] in class {$parameter->getDeclaringClass()->getName()}");
    }

    /**
     * Resolve a class based dependency from the container
     *
     * @param ReflectionParameter $parameter
     * @return mixed
     * @throws Exception
     */
    protected function resolveClass(ReflectionParameter $parameter)
    {
        try {
            return $this->make($this->getParameterClassName($parameter));
        } catch (Exception $e) {
            if ($parameter->isOptional()) {
                return $parameter->getDefaultValue();
            }

            throw $e;
        }
    }

    /**
     * Get the class name for the given parameter
     *
     * @param ReflectionParameter $parameter
     * @return string|null
     */
    protected function getParameterClassName(ReflectionParameter $parameter)
    {
        $type = $parameter->getType();

        if (!$type || $type->isBuiltin()) {
            return null;
        }

        return $type->getName();
    }

    /**
     * Determine if the given concrete is buildable
     *
     * @param mixed $concrete
     * @param string $abstract
     * @return bool
     */
    protected function isBuildable($concrete, $abstract)
    {
        return $concrete === $abstract || $concrete instanceof Closure;
    }

    /**
     * Determine if the given type is shared
     *
     * @param string $abstract
     * @return bool
     */
    public function isShared($abstract)
    {
        return isset($this->instances[$abstract]) ||
               (isset($this->bindings[$abstract]['shared']) &&
                $this->bindings[$abstract]['shared'] === true);
    }

    /**
     * Get the alias for an abstract if available
     *
     * @param string $abstract
     * @return string
     */
    public function getAlias($abstract)
    {
        if (!isset($this->aliases[$abstract])) {
            return $abstract;
        }

        return $this->getAlias($this->aliases[$abstract]);
    }

    /**
     * Get the Closure to be used when building a type
     *
     * @param string $abstract
     * @param string $concrete
     * @return Closure
     */
    protected function getClosure($abstract, $concrete)
    {
        return function ($container, $parameters = []) use ($abstract, $concrete) {
            if ($abstract == $concrete) {
                return $container->build($concrete, $parameters);
            }

            return $container->make($concrete, $parameters);
        };
    }

    /**
     * Remove an alias from the contextual binding alias cache
     *
     * @param string $searched
     * @return void
     */
    protected function removeAbstractAlias($searched)
    {
        if (!isset($this->aliases[$searched])) {
            return;
        }

        foreach ($this->aliases as $alias => $abstract) {
            if ($abstract == $searched) {
                unset($this->aliases[$alias]);
            }
        }
    }

    /**
     * Fire the "after resolving" callbacks for the given abstract type
     *
     * @param string $abstract
     * @param mixed $object
     * @return void
     */
    protected function fireAfterResolvingCallbacks($abstract, $object)
    {
        if (isset($this->afterResolvingCallbacks[$abstract])) {
            foreach ($this->afterResolvingCallbacks[$abstract] as $callback) {
                call_user_func($callback, $object, $this);
            }
        }
    }

    /**
     * Register a new after resolving callback
     *
     * @param string $abstract
     * @param callable $callback
     * @return void
     */
    public function afterResolving($abstract, callable $callback)
    {
        $this->afterResolvingCallbacks[$abstract][] = $callback;
    }

    /**
     * Determine if the given abstract type has been bound
     *
     * @param string $abstract
     * @return bool
     */
    public function bound($abstract)
    {
        return isset($this->bindings[$abstract]) ||
               isset($this->instances[$abstract]) ||
               $this->isAlias($abstract);
    }

    /**
     * Determine if the given string is an alias
     *
     * @param string $name
     * @return bool
     */
    public function isAlias($name)
    {
        return isset($this->aliases[$name]);
    }

    /**
     * Flush the container of all bindings and resolved instances
     *
     * @return void
     */
    public function flush()
    {
        $this->aliases = [];
        $this->bindings = [];
        $this->instances = [];
        $this->afterResolvingCallbacks = [];
    }
    /**
     * Invoke a callable or [ClassName, method] with container-based dependency resolution.
     * Parameters provided by name take precedence and will be type-cast for builtins.
     *
     * @param callable|array $handler
     * @param array $parameters Keyed by parameter name
     * @return mixed
     * @throws Exception
     */
    public function makeWith($handler, array $parameters = [])
    {
        // Normalize [ClassName, method] to [instance, method]
        if (is_array($handler) && isset($handler[0], $handler[1]) && is_string($handler[0])) {
            $handler = [$this->make($handler[0]), $handler[1]];
        }

        if (!is_callable($handler)) {
            throw new Exception('Invalid handler passed to makeWith.');
        }

        // Build reflection for method/function
        if (is_array($handler)) {
            $reflection = new \ReflectionMethod($handler[0], $handler[1]);
        } else {
            $reflection = new \ReflectionFunction($handler);
        }

        $args = [];
        foreach ($reflection->getParameters() as $param) {
            $name = $param->getName();
            $type = $param->getType();

            // If explicitly provided by name, prefer that
            if (array_key_exists($name, $parameters)) {
                $value = $parameters[$name];
                $args[] = $this->coerceBuiltinIfNeeded($value, $type);
                continue;
            }

            // Try to resolve class-typed parameter from container
            if ($type && !$type->isBuiltin()) {
                $className = $type->getName();
                $args[] = $this->make($className);
                continue;
            }

            // Fallback to default value for primitives
            if ($param->isDefaultValueAvailable()) {
                $args[] = $param->getDefaultValue();
                continue;
            }

            // As a last resort, pass null for missing primitives
            $args[] = null;
        }

        return call_user_func_array($handler, $args);
    }

    /**
     * Coerce provided value to the builtin type if a builtin type-hint exists.
     *
     * @param mixed $value
     * @param \ReflectionType|null $type
     * @return mixed
     */
    protected function coerceBuiltinIfNeeded($value, $type)
    {
        if (!$type || $type->isBuiltin() === false) {
            return $value;
        }

        $typeName = $type->getName();
        switch ($typeName) {
            case 'int':
                return (int) $value;
            case 'float':
                return (float) $value;
            case 'bool':
                // Accept common truthy/falsey strings
                if (is_string($value)) {
                    $lower = strtolower($value);
                    if (in_array($lower, ['1','true','on','yes'], true)) return true;
                    if (in_array($lower, ['0','false','off','no'], true)) return false;
                }
                return (bool) $value;
            case 'string':
                return (string) $value;
            case 'array':
                return (array) $value;
            default:
                return $value;
        }
    }

}
