<?php
/**
 * Yadif
 *
 * LICENSE
 *
 * This source file is subject to the new BSD license that is bundled
 * with this package in the file LICENSE.txt.
 * If you did not receive a copy of the license and are unable to
 * obtain it through the world-wide-web, please send an email
 * to tsmckelvey@gmail.com so I can send you a copy immediately.
 */

require_once 'Exception.php';

/**
 * Yadif_Container
 *
 * @category   Yadif
 * @copyright  Copyright (c) 2008 Thomas McKelvey
 * @copyright  Copyright (c) 2009 Benjamin Eberlei
 * @author     Thomas McKelvey (http://github.com/tsmckelvey/yadif/tree/master)
 * @author     Benjamin Eberlei (http://github.com/beberlei/yadif/tree/master)
 */
class Yadif_Container
{
    const CHAR_CONFIG_VALUE = '%';
    const CHAR_ARGUMENT     = ':';

    /**
     * Identifier for singleton scope
     */
    const SCOPE_SINGLETON = "singleton";

    /**
     * Identifier for prototype scope (new object each call)
     */
    const SCOPE_PROTOTYPE = "prototype";

    /**
     * Class index key of component $config
     */
    const CONFIG_CLASS = 'class';

    /**
     * Arguments index key of component $config
     */
    const CONFIG_ARGUMENTS = 'arguments';

    /**
     * Parameters
     */
    const CONFIG_PARAMETERS = 'params';

    /**
     * Singleton index key of component $config
     */
    const CONFIG_SCOPE = 'scope';

    /**
     * Setter Methods to call config index key
     */
    const CONFIG_METHODS = 'methods';

    /**
     * Method Name
     */
    const CONFIG_METHOD = 'method';

    /**
     * Factory Config key for classes that are instantiated via a static factory method
     */
    const CONFIG_FACTORY = 'factory';

    const CONFIG_DECORATED_WITH = 'decorateWith';

    /**
     * container of component configurations
     *
     * @var array
     */
    protected $_container = array();

    /**
     * parameters which have been set, expected to be bound
     *
     * @var array
     */
    protected $_parameters = array();

    /**
     * All managed instances inside this container which are not Scoped "Prototype"
     *
     * @var array
     */
    protected $_instances = array();

    /**
     * Config
     *
     * @var Zend_Config
     */
    protected $_config = null;

    /**
     * Construct Dependency Injection Container
     *
     * @param Zend_Config|array $components
     * @param Zend_Config       $config
     */
    public function __construct($components = array(), Zend_Config $config=null)
    {
        $this->addComponents($components);
        $this->setConfig($config);
    }

    /**
     * Set Config object
     *
     * @param  Zend_Config $config
     * @return Yadif_Container
     */
    public function setConfig(Zend_Config $config=null)
    {
        $this->_config = $config;
        return $this;
    }

    /**
     * Getter method for internal array of component configurations
     *
     * @return array
     */
    public function getContainer()
    {
        return $this->_container;
    }

    /**
     * Getter method for internal array of parameters
     *
     * @return array
     */
    public function getParameters()
    {
        return $this->_parameters;
    }

    /**
     * Get currently managed instances of the container.
     *
     * In its current implementations only the singleton scoped objects are returned,
     * but in an implementation with additional session scope it is necessary to return
     * also those from this function.
     *
     * @return array
     */
    public function getInstances()
    {
        return $this->_instances;
    }

    /**
     * Get Config inside this Container
     *
     * @return Zend_Config|null
     */
    public function getConfig()
    {
        return $this->_config;
    }

    /**
     * Merge two Containers
     *
     * @todo   Handle duplicates, currently array_merge overwrites them
     * @param  Yadif_Container $container
     * @return Yadif_Container
     */
    public function merge(Yadif_Container $container)
    {
        $this->_container = array_merge($this->_container, $container->getContainer());
        $this->_instances = array_merge($this->_instances, $container->getInstances());

        $otherConfig = $container->getConfig();
        $ownConfig   = $this->getConfig();
        if($otherConfig instanceof Zend_Config) {
            if($ownConfig == null) {
                $this->setConfig($otherConfig);
            } else {
                if($ownConfig->readOnly() == true) {
                    $this->setConfig(new Zend_Config(array_merge($ownConfig->toArray(), $otherConfig->toArray())));
                } else {
                    $this->setConfig($ownConfig->merge($otherConfig));
                }
            }
        }

        return $this;
    }

    /**
     * Add several components to the container via a config.
     *
     * @param array|Zend_Config $components
     * @return Yadif_Container
     */
    public function addComponents($components)
    {
        if($components instanceof Zend_Config) {
            $components = $components->toArray();
        }

        if (isset($components) && is_array($components)) {
            foreach ($components as $componentName => $componentConfig) {
                $this->addComponent( $componentName, $componentConfig );
            }
        }
    }

    /**
     * Add a component to the container
     *
     * If $config is omitted, $name is assumed to be the classname.
     *
     * @param string|Yadif_Container $name Class-tag, class, or Yadif_Container
     * @param array $config Array of configuration values
     * @return Yadif_Container
     */
    public function addComponent($name = null, array $config = null)
    {
        if ($name instanceof Yadif_Container) { // if Yadif_Container
            $this->merge($name);
        } elseif(is_string($name)) {
            if (!is_array($config) || !isset($config[self::CONFIG_CLASS])) { // assume name is the class name
                $config[self::CONFIG_CLASS] = $name;
            }

            if (!isset($config[self::CONFIG_ARGUMENTS]) || !is_array($config[self::CONFIG_ARGUMENTS])) {
                $config[ self::CONFIG_ARGUMENTS ] = array();
            }

            if(!isset($config[self::CONFIG_PARAMETERS])) {
                $config[self::CONFIG_PARAMETERS] = array();
            }

            // check for singleton config parameter and set it to true as default if not found.
            if(!isset($config[self::CONFIG_SCOPE])) {
                $config[self::CONFIG_SCOPE] = self::SCOPE_SINGLETON;
            }

            if(!isset($config[self::CONFIG_METHODS])) {
                $config[self::CONFIG_METHODS] = array();
            } elseif(!is_array($config[self::CONFIG_METHODS])) {
                throw new Yadif_Exception("Methods of component '".$name."' are not in array format.");
            } else {
                foreach($config[self::CONFIG_METHODS] AS $k => $method) {
                    if(!isset($method[self::CONFIG_METHOD])) {
                        throw new Yadif_Exception("No method name was set for injection via method.");
                    }
                    if(!isset($method[self::CONFIG_PARAMETERS])) {
                        $method[self::CONFIG_PARAMETERS] = array();
                    }
                    if(!isset($method[self::CONFIG_ARGUMENTS])) {
                        $method[self::CONFIG_ARGUMENTS] = array();
                    } elseif(!is_array($method[self::CONFIG_ARGUMENTS])) {
                        throw new Yadif_Exception("Arguments of method '".$method[self::CONFIG_METHOD]."' in component ".$name." are not given as an array.");
                    }

                    $config[self::CONFIG_METHODS][$k] = $method;
                }
            }

            $name = strtolower($name);
            $this->_container[$name] = $config;
        } else {
            throw new Yadif_Exception('$string not string|Yadif_Container, is ' . gettype($name));
        }

        return $this;
    }

    /**
     * Bind a parameter
     *
     * @param string $param The parameter name, to be given with a leading colon ":param"
     * @param mixed $value The value to bind to the parameter
     * @return Yadif_Container
     */
    public function bindParam($param, $value)
    {
        if (!is_string($param)) {
            throw new Yadif_Exception('$param not string, is ' . gettype($param));
        }

        if ($param[0] != ':') {
            throw new Yadif_Exception($param . ' must start with a colon (:)');
        }

        $this->_parameters[$param] = $value;

        return $this;
    }

    /**
     * Retrieve a parameter by name
     *
     * @param  mixed $param Retrieve named parameter
     * @param  string $component
     * @param  string $method
     * @return mixed
     */
    public function getParam($param, $component=null)
    {
        $component = strtolower($component);
        if(isset($this->_container[$component])) {
            $component = $this->_container[$component];
            if(isset($component[self::CONFIG_PARAMETERS][$param])) {
                return $component[self::CONFIG_PARAMETERS][$param];
            }
        }

        if(isset($this->_parameters[$param])) {
            return $this->_parameters[$param];
        } else {
            return null;
        }
    }

    /**
     * Bind multiple parameters by way of array
     * @param array $params Array of parameters, key as param to bind, value as the bound value
     * @return Yadif_Container
     */
    public function bindParams($params = null)
    {
        if (!is_array($params)) {
            throw new Yadif_Exception('$params must be array, is ' . gettype($params));
        }

        foreach ($params as $param => $value) {
            $this->bindParam($param, $value);
        }

        return $this;
    }

    /**
     * Get several components at once.
     *
     * @param  array $components
     * @return array
     */
    public function getComponents(array $components)
    {
        foreach ($components as $k =>$value) {
            $components[$k] = $this->getComponent($value);
        }
        return $components;
    }

    protected function injectParameters($arguments, $component, array $params=array())
    {
        $injection = array();
        foreach($arguments AS $k => $argument) {
            if(is_array($argument)) {
                $value = $this->injectParameters($argument, $component);
            } elseif(substr($argument, 0, 1) == self::CHAR_CONFIG_VALUE && substr($argument, -1) == self::CHAR_CONFIG_VALUE) {
                $value = $this->getConfigValue($argument);
            } elseif(substr($argument, 0, 1) == self::CHAR_ARGUMENT) {
                if(isset($params[$argument])) {
                    $value =  $params[$argument];
                } else {
                    $value =  $this->getParam($argument, $component);
                }
            } else {
                $value = $this->getComponent($argument);
            }
            $injection[$k] = $value;
        }
        return $injection;
    }

    /**
     * Clear a current instance.
     * 
     * @param string $name
     */
    public function clear($name)
    {
        unset($this->_instances[$name]);
    }

    /**
     * Get back a fully assembled component based on the configuration provided beforehand
     *
     * @param  string $name The name of the component
     * @return mixed
     */
    public function getComponent($name)
    {
        if (!is_string($name)) {
            return $name;
        }

        $origName = $name;
        $name = strtolower($name);

        if(isset($this->_instances[$name])) {
            return $this->_instances[$name];
        }

        if($name === "thiscontainer") {
            return $this;
        } elseif($name === "clonecontainer") {
            return clone $this;
        } elseif(!array_key_exists($name, $this->_container)) {
            throw new Yadif_Exception("Component '".$origName."' does not exist in container.");
        }

        $component = $this->_container[$name];
        $scope = $component[self::CONFIG_SCOPE];

        // if class is set and doesn't exist
        if (!class_exists( $component[self::CONFIG_CLASS] )) {
            throw new Yadif_Exception( 'Class ' . $component[self::CONFIG_CLASS] . ' not found' );
        }

        $componentReflection = new ReflectionClass($component[ self::CONFIG_CLASS ]);

        $constructorArguments = $component[self::CONFIG_ARGUMENTS];
        $setterMethods        = $component[self::CONFIG_METHODS];

        if(isset($component[self::CONFIG_FACTORY])) {
            if(!is_callable($component[self::CONFIG_FACTORY])) {
                throw new Yadif_Exception("No valid callback given for the factory method of '".$origName."'.");
            }
            $componentInstance = call_user_func_array($component[self::CONFIG_FACTORY], $this->injectParameters($constructorArguments, $name));
        } else if(empty($constructorArguments)) { // if no instructions
                $componentInstance = $componentReflection->newInstance();
            } else {
                $componentInstance = $componentReflection->newInstanceArgs($this->injectParameters($constructorArguments, $name));
            }

        foreach ($setterMethods as $method) {
            $methodName = $method[self::CONFIG_METHOD];
            $params = $method[self::CONFIG_PARAMETERS];

            $injection = array();
            if(isset($method[self::CONFIG_ARGUMENTS])) {
                $argsName = $method[self::CONFIG_ARGUMENTS];
                $injection = $this->injectParameters($argsName, null, $params);
            }

            if ($componentReflection->getMethod($methodName)->isConstructor()) {
                throw new Yadif_Exception("Cannot use constructor in 'methods' setter injection list. Use 'arguments' key instead.");
            } else {
                call_user_func_array(array($componentInstance, $methodName), $injection);
            }
        }

        if (isset($component[self::CONFIG_DECORATED_WITH])) {
            foreach ($component[self::CONFIG_DECORATED_WITH] AS $decoratorComponent) {
                $this->_instances['decoratedinstance'] = $componentInstance;
                $componentInstance = $this->getComponent($decoratorComponent);
            }
            unset($this->_instances['decoratedinstance']);
        }

        if($scope !== self::SCOPE_PROTOTYPE) {
            $this->_instances[$name] = $componentInstance;
        }

        return $componentInstance;
    }

    /**
     * Magic Get accesses the {@link getComponent} function and returns the object.
     *
     * @param  string $component
     * @throws Yadif_Exception
     * @return object
     */
    public function __get($name)
    {
        return $this->getComponent($name);
    }

    /**
     * Call allows to initiate requests to get[ComponetName] and links against {@link getComponent}.
     *
     * @param  string $method
     * @param  array  $arguments
     * @throws Yadif_Exception
     * @return object
     */
    public function __call($method, $args)
    {
        if(substr($method, 0, 3) !== "get") {
            throw new Yadif_Exception("Container __call only intercepts get[ComponetName]() like calls.");
        }
        $component = substr($method, 3);
        return $this->getComponent($component);
    }

    /**
     * Set instance of a name.
     *
     * @throws Yadif_Exception
     * @param string $name
     * @param object $instance
     */
    public function __set($name, $instance)
    {
        $origName = $name;
        $name = strtolower($name);
        if(is_object($instance)) {
            $this->_instances[$name] = $instance;
        } else {
            throw new Yadif_Exception("Setting instance '".$origName."' with non-object not possible.");
        }
    }

    /**
     * Clear an instance.
     * 
     * @param string $name
     */
    public function __unset($name)
    {
        $this->_instances[$name];
    }

    /**
     * Is there an instance or a component registered with the given name?
     *
     * @param  string $name
     * @return boolean
     */
    public function __isset($name)
    {
        $name = strtolower($name);
        return (
        isset($this->_instances[$name]) ||
            isset($this->_container[$name])
        );
    }

    /**
     * Given an accessor specification %config.foo.bar% traverse the config and return value.
     *
     * @param  string $accessor
     * @return mixed
     */
    protected function getConfigValue($accessor)
    {
        if($this->_config === null) {
            throw new Yadif_Exception("A config value '".$accessor."' is required but no config was given!");
        }

        $accessor = substr($accessor, 1, strlen($accessor)-2);

        $parts = explode(".", $accessor);
        $current = $this->_config;
        for($i = 0; $i < count($parts); $i++) {
            $current = $current->$parts[$i];
        }
        return $current;
    }
}
