/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.shiro.config.ogdl;

import java.beans.PropertyDescriptor;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;

import org.apache.commons.beanutils.BeanUtilsBean;
import org.apache.commons.beanutils.ConvertUtilsBean;
import org.apache.commons.beanutils.FluentPropertyBeanIntrospector;
import org.apache.commons.beanutils.SuppressPropertiesBeanIntrospector;
import org.apache.shiro.lang.codec.Base64;
import org.apache.shiro.lang.codec.Hex;
import org.apache.shiro.config.ConfigurationException;
import org.apache.shiro.config.ogdl.event.BeanEvent;
import org.apache.shiro.config.ogdl.event.ConfiguredBeanEvent;
import org.apache.shiro.config.ogdl.event.DestroyedBeanEvent;
import org.apache.shiro.config.ogdl.event.InitializedBeanEvent;
import org.apache.shiro.config.ogdl.event.InstantiatedBeanEvent;
import org.apache.shiro.event.EventBus;
import org.apache.shiro.event.EventBusAware;
import org.apache.shiro.event.Subscribe;
import org.apache.shiro.event.support.DefaultEventBus;
import org.apache.shiro.lang.util.Assert;
import org.apache.shiro.lang.util.ByteSource;
import org.apache.shiro.lang.util.ClassUtils;
import org.apache.shiro.lang.util.Factory;
import org.apache.shiro.lang.util.LifecycleUtils;
import org.apache.shiro.lang.util.Nameable;
import org.apache.shiro.lang.util.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;


/**
 * Object builder that uses reflection and Apache Commons BeanUtils to build objects given a
 * map of "property values".  Typically these come from the Shiro INI configuration and are used
 * to construct or modify the SecurityManager, its dependencies, and web-based security filters.
 * <p/>
 * Recognizes {@link Factory} implementations and will call
 * {@link org.apache.shiro.lang.util.Factory#getInstance() getInstance} to satisfy any reference to this bean.
 *
 * @since 0.9
 */
@SuppressWarnings("checkstyle:MethodCount")
public class ReflectionBuilder {

    private static final Logger LOGGER = LoggerFactory.getLogger(ReflectionBuilder.class);

    private static final String OBJECT_REFERENCE_BEGIN_TOKEN = "$";
    private static final String ESCAPED_OBJECT_REFERENCE_BEGIN_TOKEN = "\\$";
    private static final String GLOBAL_PROPERTY_PREFIX = "shiro";
    private static final char MAP_KEY_VALUE_DELIMITER = ':';
    private static final String HEX_BEGIN_TOKEN = "0x";
    private static final String NULL_VALUE_TOKEN = "null";
    private static final String EMPTY_STRING_VALUE_TOKEN = "\"\"";
    private static final char STRING_VALUE_DELIMITER = '"';
    private static final char MAP_PROPERTY_BEGIN_TOKEN = '[';
    private static final char MAP_PROPERTY_END_TOKEN = ']';

    private static final String EVENT_BUS_NAME = "eventBus";

    private final Map<String, Object> objects;

    /**
     * Interpolation allows for ${key} substitution of values.
     *
     * @since 1.4
     */
    private Interpolator interpolator;

    /**
     * @since 1.3
     */
    private EventBus eventBus;

    /**
     * Keeps track of event subscribers that were automatically registered by this ReflectionBuilder during
     * object construction.  This is used in case a new EventBus is discovered during object graph
     * construction:  upon discovery of the new EventBus, the existing subscribers will be unregistered from the
     * old EventBus and then re-registered with the new EventBus.
     *
     * @since 1.3
     */
    private final Map<String, Object> registeredEventSubscribers;

    /**
     * @since 1.4
     */
    private final BeanUtilsBean beanUtilsBean;

    private Function<String, ?> alternateObjectSupplier = name -> null;

    public ReflectionBuilder() {
        this(null);
    }

    public ReflectionBuilder(Map<String, ?> defaults) {

        // SHIRO-619
        // SHIRO-739
        beanUtilsBean = new BeanUtilsBean(new ConvertUtilsBean() {
            @Override
            @SuppressWarnings("unchecked")
            public Object convert(String value, Class<?> clazz) {
                if (clazz.isEnum()) {
                    return Enum.valueOf((Class<Enum>) clazz, value);
                } else {
                    return super.convert(value, clazz);
                }
            }
        });
        beanUtilsBean.getPropertyUtils().addBeanIntrospector(SuppressPropertiesBeanIntrospector.SUPPRESS_CLASS);
        beanUtilsBean.getPropertyUtils().addBeanIntrospector(new FluentPropertyBeanIntrospector());

        this.interpolator = createInterpolator();

        this.objects = createDefaultObjectMap();
        this.registeredEventSubscribers = new LinkedHashMap<>();
        apply(defaults);
    }

    private void apply(Map<String, ?> objects) {
        if (!isEmpty(objects)) {
            this.objects.putAll(objects);
        }
        EventBus found = findEventBus(this.objects);
        Assert.notNull(found, "An " + EventBus.class.getName() + " instance must be present in the object defaults");
        enableEvents(found);
    }

    //@since 1.3
    private Map<String, Object> createDefaultObjectMap() {
        Map<String, Object> map = new LinkedHashMap<String, Object>();
        map.put(EVENT_BUS_NAME, new DefaultEventBus());
        return map;
    }

    public Map<String, ?> getObjects() {
        return objects;
    }

    /**
     * @param objects
     */
    public void setObjects(Map<String, ?> objects) {
        this.objects.clear();
        this.objects.putAll(createDefaultObjectMap());
        apply(objects);
    }

    //@since 1.3
    private void enableEvents(EventBus eventBus) {
        Assert.notNull(eventBus, "EventBus argument cannot be null.");
        //clean up old auto-registered subscribers:
        for (Object subscriber : this.registeredEventSubscribers.values()) {
            this.eventBus.unregister(subscriber);
        }
        this.registeredEventSubscribers.clear();

        this.eventBus = eventBus;

        for (Map.Entry<String, Object> entry : this.objects.entrySet()) {
            enableEventsIfNecessary(entry.getValue(), entry.getKey());
        }
    }

    //@since 1.3
    private void enableEventsIfNecessary(Object bean, String name) {
        boolean applied = applyEventBusIfNecessary(bean);
        if (!applied) {
            //if the event bus is applied, and the bean wishes to be a subscriber as well (not just a publisher),
            // we assume that the implementation registers itself with the event bus, i.e. eventBus.register(this);

            //if the event bus isn't applied, only then do we need to check to see if the bean is an event subscriber,
            // and if so, register it on the event bus automatically since it has no ability to do so itself:
            if (isEventSubscriber(bean, name)) {
                //found an event subscriber, so register them with the EventBus:
                this.eventBus.register(bean);
                this.registeredEventSubscribers.put(name, bean);
            }
        }
    }

    //@since 1.3
    private boolean isEventSubscriber(Object bean, String name) {
        List<?> annotatedMethods = ClassUtils.getAnnotatedMethods(bean.getClass(), Subscribe.class);
        return !isEmpty(annotatedMethods);
    }

    /**
     * Plug in another way to get objects into configuration, ex: CDI
     *
     * @param alternateObjectSupplier not null (empty lambda ok)
     * @since 2.0
     */
    public void setAlternateObjectSupplier(Function<String, ?> alternateObjectSupplier) {
        this.alternateObjectSupplier = alternateObjectSupplier;
    }

    //@since 1.3
    protected EventBus findEventBus(Map<String, ?> objects) {

        if (isEmpty(objects)) {
            return null;
        }

        //prefer a named object first:
        Object value = objects.get(EVENT_BUS_NAME);
        if (value instanceof EventBus) {
            return (EventBus) value;
        }

        //couldn't find a named 'eventBus' EventBus object.  Try to find the first typed value we can:
        for (Object v : objects.values()) {
            if (v instanceof EventBus) {
                return (EventBus) v;
            }
        }

        return null;
    }

    private boolean applyEventBusIfNecessary(Object value) {
        if (value instanceof EventBusAware) {
            ((EventBusAware) value).setEventBus(this.eventBus);
            return true;
        }
        return false;
    }

    public Object getBean(String id) {
        return objects.get(id);
    }

    @SuppressWarnings("unchecked")
    public <T> T getBean(String id, Class<T> requiredType) {
        if (requiredType == null) {
            throw new NullPointerException("requiredType argument cannot be null.");
        }
        Object bean = getBean(id);
        if (bean == null) {
            return null;
        }
        Assert.state(requiredType.isAssignableFrom(bean.getClass()),
                "Bean with id [" + id + "] is not of the required type [" + requiredType.getName() + "].");
        return (T) bean;
    }

    private String parseBeanId(String lhs) {
        Assert.notNull(lhs);
        if (lhs.indexOf('.') < 0) {
            return lhs;
        }
        String classSuffix = ".class";
        int index = lhs.indexOf(classSuffix);
        if (index >= 0) {
            return lhs.substring(0, index);
        }
        return null;
    }

    @SuppressWarnings({"unchecked"})
    public Map<String, ?> buildObjects(Map<String, String> kvPairs) {

        if (kvPairs != null && !kvPairs.isEmpty()) {

            BeanConfigurationProcessor processor = new BeanConfigurationProcessor();

            for (Map.Entry<String, String> entry : kvPairs.entrySet()) {
                String lhs = entry.getKey();
                String rhs = interpolator.interpolate(entry.getValue());

                String beanId = parseBeanId(lhs);
                //a beanId could be parsed, so the line is a bean instance definition
                if (beanId != null) {
                    processor.add(new InstantiationStatement(beanId, rhs));
                    //the line must be a property configuration
                } else {
                    processor.add(new AssignmentStatement(lhs, rhs));
                }
            }

            processor.execute();

            //SHIRO-778: onInit method on AuthenticatingRealm is called twice
            objects.keySet().stream()
                    .filter(key -> !kvPairs.containsKey(key))
                    .forEach(key -> LifecycleUtils.init(objects.get(key)));
        } else {
            //SHIRO-413: init method must be called for constructed objects that are Initializable
            LifecycleUtils.init(objects.values());
        }

        return objects;
    }

    public void destroy() {
        final Map<String, Object> immutableObjects = Collections.unmodifiableMap(objects);

        //destroy objects in the opposite order they were initialized:
        List<Map.Entry<String, ?>> entries = new ArrayList<Map.Entry<String, ?>>(objects.entrySet());
        Collections.reverse(entries);

        for (Map.Entry<String, ?> entry : entries) {
            String id = entry.getKey();
            Object bean = entry.getValue();

            //don't destroy the eventbus until the end - we need it to still be 'alive' while publishing destroy events:
            //memory equality check (not .equals) on purpose
            if (bean != this.eventBus) {
                LifecycleUtils.destroy(bean);
                BeanEvent event = new DestroyedBeanEvent(id, bean, immutableObjects);
                eventBus.publish(event);
                //bean is now destroyed - it should not receive any other events
                this.eventBus.unregister(bean);
            }
        }
        //only now destroy the event bus:
        LifecycleUtils.destroy(this.eventBus);
    }

    protected void createNewInstance(Map<String, Object> objects, String name, String value) {

        Object currentInstance = objects.get(name);
        if (currentInstance != null) {
            LOGGER.info("An instance with name '{}' already exists.  "
                    + "Redefining this object as a new instance of type {}", name, value);
        }

        //name with no property, assume right hand side of equals sign is the class name:
        Object instance;
        try {
            instance = ClassUtils.newInstance(value);
            if (instance instanceof Nameable) {
                ((Nameable) instance).setName(name);
            }
        } catch (Exception e) {
            instance = alternateObjectSupplier.apply(value);
            if (instance == null) {
                String msg = "Unable to instantiate class [" + value + "] for object named '" + name + "'.  "
                        + "Please ensure you've specified the fully qualified class name correctly.";
                throw new ConfigurationException(msg, e);
            }
        }
        objects.put(name, instance);
    }

    protected void applyProperty(String key, String value, Map objects) {

        int index = key.indexOf('.');

        if (index >= 0) {
            String name = key.substring(0, index);
            String property = key.substring(index + 1, key.length());

            if (GLOBAL_PROPERTY_PREFIX.equalsIgnoreCase(name)) {
                applyGlobalProperty(objects, property, value);
            } else {
                applySingleProperty(objects, name, property, value);
            }

        } else {
            throw new IllegalArgumentException("All property keys must contain a '.' character. "
                    + "(e.g. myBean.property = value)  These should already be separated out by buildObjects().");
        }
    }

    protected void applyGlobalProperty(Map objects, String property, String value) {
        for (Object instance : objects.values()) {
            try {
                PropertyDescriptor pd = beanUtilsBean.getPropertyUtils().getPropertyDescriptor(instance, property);
                if (pd != null) {
                    applyProperty(instance, property, value);
                }
            } catch (Exception e) {
                String msg = "Error retrieving property descriptor for instance "
                        + "of type [" + instance.getClass().getName() + "] "
                        + "while setting property [" + property + "]";
                throw new ConfigurationException(msg, e);
            }
        }
    }

    protected void applySingleProperty(Map objects, String name, String property, String value) {
        Object instance = objects.get(name);
        if (property.equals("class")) {
            throw new IllegalArgumentException("Property keys should not contain 'class' properties since these "
                    + "should already be separated out by buildObjects().");

        } else if (instance == null) {
            String msg = "Configuration error.  Specified object [" + name + "] with property ["
                    + property + "] without first defining that object's class.  Please first "
                    + "specify the class property first, e.g. myObject = fully_qualified_class_name "
                    + "and then define additional properties.";
            throw new IllegalArgumentException(msg);

        } else {
            applyProperty(instance, property, value);
        }
    }

    protected boolean isReference(String value) {
        return value != null && value.startsWith(OBJECT_REFERENCE_BEGIN_TOKEN);
    }

    protected String getId(String referenceToken) {
        return referenceToken.substring(OBJECT_REFERENCE_BEGIN_TOKEN.length());
    }

    protected Object getReferencedObject(String id) {
        Object o = objects != null && !objects.isEmpty() ? objects.get(id) : null;
        if (o == null) {
            String msg = "The object with id [" + id + "] has not yet been defined and therefore cannot be "
                    + "referenced.  Please ensure objects are defined in the order in which they should be "
                    + "created and made available for future reference.";
            throw new UnresolveableReferenceException(msg);
        }
        return o;
    }

    protected String unescapeIfNecessary(String value) {
        if (value != null && value.startsWith(ESCAPED_OBJECT_REFERENCE_BEGIN_TOKEN)) {
            return value.substring(ESCAPED_OBJECT_REFERENCE_BEGIN_TOKEN.length() - 1);
        }
        return value;
    }

    protected Object resolveReference(String reference) {
        String id = getId(reference);
        LOGGER.debug("Encountered object reference '{}'.  Looking up object with id '{}'", reference, id);
        final Object referencedObject = getReferencedObject(id);
        if (referencedObject instanceof Factory) {
            return ((Factory) referencedObject).getInstance();
        }
        return referencedObject;
    }

    protected boolean isTypedProperty(Object object, String propertyName, Class<?> clazz) {
        if (clazz == null) {
            throw new NullPointerException("type (class) argument cannot be null.");
        }
        try {
            PropertyDescriptor descriptor = beanUtilsBean.getPropertyUtils().getPropertyDescriptor(object, propertyName);
            if (descriptor == null) {
                String msg = "Property '" + propertyName + "' does not exist for object of "
                        + "type " + object.getClass().getName() + ".";
                throw new ConfigurationException(msg);
            }
            Class<?> propertyClazz = descriptor.getPropertyType();
            return clazz.isAssignableFrom(propertyClazz);
        } catch (ConfigurationException ce) {
            //let it propagate:
            throw ce;
        } catch (Exception e) {
            String msg = "Unable to determine if property [" + propertyName + "] represents a " + clazz.getName();
            throw new ConfigurationException(msg, e);
        }
    }

    protected Set<?> toSet(String sValue) {
        String[] tokens = StringUtils.split(sValue);
        if (tokens == null || tokens.length <= 0) {
            return null;
        }

        //SHIRO-423: check to see if the value is a referenced Set already, and if so, return it immediately:
        if (tokens.length == 1 && isReference(tokens[0])) {
            Object reference = resolveReference(tokens[0]);
            if (reference instanceof Set) {
                return (Set) reference;
            }
        }

        Set<String> setTokens = new LinkedHashSet<String>(Arrays.asList(tokens));

        //now convert into correct values and/or references:
        Set<Object> values = new LinkedHashSet<Object>(setTokens.size());
        for (String token : setTokens) {
            Object value = resolveValue(token);
            values.add(value);
        }
        return values;
    }

    protected Map<?, ?> toMap(String sValue) {
        String[] tokens = StringUtils.split(sValue, StringUtils.DEFAULT_DELIMITER_CHAR,
                StringUtils.DEFAULT_QUOTE_CHAR, StringUtils.DEFAULT_QUOTE_CHAR, true, true);
        if (tokens == null || tokens.length <= 0) {
            return null;
        }

        //SHIRO-423: check to see if the value is a referenced Map already, and if so, return it immediately:
        if (tokens.length == 1 && isReference(tokens[0])) {
            Object reference = resolveReference(tokens[0]);
            if (reference instanceof Map) {
                return (Map) reference;
            }
        }

        Map<String, String> mapTokens = new LinkedHashMap<String, String>(tokens.length);
        for (String token : tokens) {
            String[] kvPair = StringUtils.split(token, MAP_KEY_VALUE_DELIMITER);
            if (kvPair == null || kvPair.length != 2) {
                String msg = "Map property value [" + sValue + "] contained key-value pair token ["
                        + token + "] that does not properly split to a single key and pair.  This must be the "
                        + "case for all map entries.";
                throw new ConfigurationException(msg);
            }
            mapTokens.put(kvPair[0], kvPair[1]);
        }

        //now convert into correct values and/or references:
        Map<Object, Object> map = new LinkedHashMap<Object, Object>(mapTokens.size());
        for (Map.Entry<String, String> entry : mapTokens.entrySet()) {
            Object key = resolveValue(entry.getKey());
            Object value = resolveValue(entry.getValue());
            map.put(key, value);
        }
        return map;
    }

    // @since 1.2.2
    protected Collection<?> toCollection(String sValue) {

        String[] tokens = StringUtils.split(sValue);
        if (tokens == null || tokens.length <= 0) {
            return null;
        }

        //SHIRO-423: check to see if the value is a referenced Collection already, and if so, return it immediately:
        if (tokens.length == 1 && isReference(tokens[0])) {
            Object reference = resolveReference(tokens[0]);
            if (reference instanceof Collection) {
                return (Collection) reference;
            }
        }

        //now convert into correct values and/or references:
        List<Object> values = new ArrayList<Object>(tokens.length);
        for (String token : tokens) {
            Object value = resolveValue(token);
            values.add(value);
        }
        return values;
    }

    protected List<?> toList(String sValue) {
        String[] tokens = StringUtils.split(sValue);
        if (tokens == null || tokens.length <= 0) {
            return null;
        }

        //SHIRO-423: check to see if the value is a referenced List already, and if so, return it immediately:
        if (tokens.length == 1 && isReference(tokens[0])) {
            Object reference = resolveReference(tokens[0]);
            if (reference instanceof List) {
                return (List) reference;
            }
        }

        //now convert into correct values and/or references:
        List<Object> values = new ArrayList<Object>(tokens.length);
        for (String token : tokens) {
            Object value = resolveValue(token);
            values.add(value);
        }
        return values;
    }

    protected byte[] toBytes(String sValue) {
        if (sValue == null) {
            return null;
        }
        byte[] bytes;
        if (sValue.startsWith(HEX_BEGIN_TOKEN)) {
            String hex = sValue.substring(HEX_BEGIN_TOKEN.length());
            bytes = Hex.decode(hex);
        } else {
            //assume base64 encoded:
            bytes = Base64.decode(sValue);
        }
        return bytes;
    }

    protected Object resolveValue(String stringValue) {
        Object value;
        if (isReference(stringValue)) {
            value = resolveReference(stringValue);
        } else {
            value = unescapeIfNecessary(stringValue);
        }
        return value;
    }

    protected String checkForNullOrEmptyLiteral(String stringValue) {
        if (stringValue == null) {
            return null;
        }
        //check if the value is the actual literal string 'null' (expected to be wrapped in quotes):
        if ("\"null\"".equals(stringValue)) {
            return NULL_VALUE_TOKEN;
            //or the actual literal string of two quotes '""' (expected to be wrapped in quotes):
        } else if ("\"\"\"\"".equals(stringValue)) {
            return EMPTY_STRING_VALUE_TOKEN;
        } else {
            return stringValue;
        }
    }

    protected void applyProperty(Object object, String propertyPath, Object value) {

        int mapBegin = propertyPath.indexOf(MAP_PROPERTY_BEGIN_TOKEN);
        int mapEnd = -1;
        String mapPropertyPath = null;
        String keyString = null;

        String remaining = null;

        if (mapBegin >= 0) {
            //a map is being referenced in the overall property path.  Find just the map's path:
            mapPropertyPath = propertyPath.substring(0, mapBegin);
            //find the end of the map reference:
            mapEnd = propertyPath.indexOf(MAP_PROPERTY_END_TOKEN, mapBegin);
            //find the token in between the [ and the ] (the map/array key or index):
            keyString = propertyPath.substring(mapBegin + 1, mapEnd);

            //find out if there is more path reference to follow.  If not, we're at a terminal of the OGNL expression
            if (propertyPath.length() > (mapEnd + 1)) {
                remaining = propertyPath.substring(mapEnd + 1);
                if (remaining.startsWith(".")) {
                    remaining = StringUtils.clean(remaining.substring(1));
                }
            }
        }

        if (remaining == null) {
            //we've terminated the OGNL expression.  Check to see if we're assigning a property or a map entry:
            if (keyString == null) {
                //not a map or array value assignment - assign the property directly:
                setProperty(object, propertyPath, value);
            } else {
                //we're assigning a map or array entry.  Check to see which we should call:
                if (isTypedProperty(object, mapPropertyPath, Map.class)) {
                    @SuppressWarnings("unchecked")
                    var map = (Map<Object, Object>) getProperty(object, mapPropertyPath);
                    Object mapKey = resolveValue(keyString);
                    //noinspection unchecked
                    map.put(mapKey, value);
                } else {
                    //must be an array property.  Convert the key string to an index:
                    int index = Integer.valueOf(keyString);
                    setIndexedProperty(object, mapPropertyPath, index, value);
                }
            }
        } else {
            //property is being referenced as part of a nested path.  Find the referenced map/array entry and
            //recursively call this method with the remaining property path
            Object referencedValue = null;
            if (isTypedProperty(object, mapPropertyPath, Map.class)) {
                Map map = (Map) getProperty(object, mapPropertyPath);
                Object mapKey = resolveValue(keyString);
                referencedValue = map.get(mapKey);
            } else {
                //must be an array property:
                int index = Integer.valueOf(keyString);
                referencedValue = getIndexedProperty(object, mapPropertyPath, index);
            }

            if (referencedValue == null) {
                throw new ConfigurationException("Referenced map/array value '" + mapPropertyPath + "["
                        + keyString + "]' does not exist.");
            }

            applyProperty(referencedValue, remaining, value);
        }
    }

    private void setProperty(Object object, String propertyPath, Object value) {
        try {
            if (LOGGER.isTraceEnabled()) {
                LOGGER.trace("Applying property [{}] value [{}] on object of type [{}]",
                        new Object[] {propertyPath, value, object.getClass().getName()});
            }
            beanUtilsBean.setProperty(object, propertyPath, value);
        } catch (Exception e) {
            String msg = "Unable to set property '" + propertyPath + "' with value [" + value + "] on object "
                    + "of type " + (object != null ? object.getClass().getName() : null) + ".  If "
                    + "'" + value + "' is a reference to another (previously defined) object, prefix it with "
                    + "'" + OBJECT_REFERENCE_BEGIN_TOKEN + "' to indicate that the referenced "
                    + "object should be used as the actual value.  "
                    + "For example, " + OBJECT_REFERENCE_BEGIN_TOKEN + value;
            throw new ConfigurationException(msg, e);
        }
    }

    private Object getProperty(Object object, String propertyPath) {
        try {
            return beanUtilsBean.getPropertyUtils().getProperty(object, propertyPath);
        } catch (Exception e) {
            throw new ConfigurationException("Unable to access property '" + propertyPath + "'", e);
        }
    }

    private void setIndexedProperty(Object object, String propertyPath, int index, Object value) {
        try {
            beanUtilsBean.getPropertyUtils().setIndexedProperty(object, propertyPath, index, value);
        } catch (Exception e) {
            throw new ConfigurationException("Unable to set array property '" + propertyPath + "'", e);
        }
    }

    private Object getIndexedProperty(Object object, String propertyPath, int index) {
        try {
            return beanUtilsBean.getPropertyUtils().getIndexedProperty(object, propertyPath, index);
        } catch (Exception e) {
            throw new ConfigurationException("Unable to acquire array property '" + propertyPath + "'", e);
        }
    }

    protected boolean isIndexedPropertyAssignment(String propertyPath) {
        return propertyPath.endsWith("" + MAP_PROPERTY_END_TOKEN);
    }

    protected void applyProperty(Object object, String propertyName, String stringValue) {

        Object value;

        if (NULL_VALUE_TOKEN.equals(stringValue)) {
            value = null;
        } else if (EMPTY_STRING_VALUE_TOKEN.equals(stringValue)) {
            value = StringUtils.EMPTY_STRING;
        } else if (isIndexedPropertyAssignment(propertyName)) {
            String checked = checkForNullOrEmptyLiteral(stringValue);
            value = resolveValue(checked);
        } else if (isTypedProperty(object, propertyName, Set.class)) {
            value = toSet(stringValue);
        } else if (isTypedProperty(object, propertyName, Map.class)) {
            value = toMap(stringValue);
        } else if (isTypedProperty(object, propertyName, List.class)) {
            value = toList(stringValue);
        } else if (isTypedProperty(object, propertyName, Collection.class)) {
            value = toCollection(stringValue);
        } else if (isTypedProperty(object, propertyName, byte[].class)) {
            value = toBytes(stringValue);
        } else if (isTypedProperty(object, propertyName, ByteSource.class)) {
            byte[] bytes = toBytes(stringValue);
            value = ByteSource.Util.bytes(bytes);
        } else {
            String checked = checkForNullOrEmptyLiteral(stringValue);
            value = resolveValue(checked);
        }

        applyProperty(object, propertyName, value);
    }

    private Interpolator createInterpolator() {

        if (ClassUtils.isAvailable("org.apache.commons.configuration2.interpol.ConfigurationInterpolator")) {
            return new CommonsInterpolator();
        }

        return new DefaultInterpolator();
    }

    /**
     * Sets the {@link Interpolator} used when evaluating the right side of the expressions.
     *
     * @since 1.4
     */
    public void setInterpolator(Interpolator interpolator) {
        this.interpolator = interpolator;
    }

    private final class BeanConfigurationProcessor {

        private final List<Statement> statements = new ArrayList<Statement>();
        private final List<BeanConfiguration> beanConfigurations = new ArrayList<BeanConfiguration>();

        public void add(Statement statement) {
            //we execute bean configuration statements in the order they are declared.
            statements.add(statement);

            if (statement instanceof InstantiationStatement) {
                InstantiationStatement is = (InstantiationStatement) statement;
                beanConfigurations.add(new BeanConfiguration(is));
            } else {
                AssignmentStatement as = (AssignmentStatement) statement;
                //statements always apply to the most recently defined bean configuration with the same name, so we
                //have to traverse the configuration list starting at the end (most recent elements are appended):
                boolean addedToConfig = false;
                String beanName = as.getRootBeanName();
                for (int i = beanConfigurations.size() - 1; i >= 0; i--) {
                    BeanConfiguration mostRecent = beanConfigurations.get(i);
                    String mostRecentBeanName = mostRecent.getBeanName();
                    if (beanName.equals(mostRecentBeanName)) {
                        mostRecent.add(as);
                        addedToConfig = true;
                        break;
                    }
                }

                if (!addedToConfig) {
                    // the AssignmentStatement must be for an existing bean that does not yet have a corresponding
                    // configuration object (this would happen if the bean is in the default objects map). Because
                    // BeanConfiguration instances don't exist for default (already instantiated) beans,
                    // we simulate a creation of one to satisfy this processors implementation:
                    beanConfigurations.add(new BeanConfiguration(as));
                }
            }
        }

        public void execute() {

            for (Statement statement : statements) {

                statement.execute();

                BeanConfiguration bd = statement.getBeanConfiguration();

                //bean is fully configured, no more statements to execute for it:
                if (bd.isExecuted()) {

                    //bean configured overrides the 'eventBus' bean - replace the existing eventBus with the one configured:
                    if (bd.getBeanName().equals(EVENT_BUS_NAME)) {
                        EventBus eventBus = (EventBus) bd.getBean();
                        enableEvents(eventBus);
                    }

                    //ignore global 'shiro.' shortcut mechanism:
                    if (!bd.isGlobalConfig()) {
                        BeanEvent event = new ConfiguredBeanEvent(bd.getBeanName(), bd.getBean(),
                                Collections.unmodifiableMap(objects));
                        eventBus.publish(event);
                    }

                    //initialize the bean if necessary:
                    LifecycleUtils.init(bd.getBean());

                    //ignore global 'shiro.' shortcut mechanism:
                    if (!bd.isGlobalConfig()) {
                        BeanEvent event = new InitializedBeanEvent(bd.getBeanName(), bd.getBean(),
                                Collections.unmodifiableMap(objects));
                        eventBus.publish(event);
                    }
                }
            }
        }
    }

    private final class BeanConfiguration {

        private final InstantiationStatement instantiationStatement;
        private final List<AssignmentStatement> assignments = new ArrayList<AssignmentStatement>();
        private final String beanName;
        private Object bean;

        private BeanConfiguration(InstantiationStatement statement) {
            statement.setBeanConfiguration(this);
            this.instantiationStatement = statement;
            this.beanName = statement.lhs;
        }

        private BeanConfiguration(AssignmentStatement as) {
            this.instantiationStatement = null;
            this.beanName = as.getRootBeanName();
            add(as);
        }

        public String getBeanName() {
            return this.beanName;
        }

        /**
         * BeanConfiguration instance representing the global 'shiro.' properties
         *
         * @return boolean
         */
        public boolean isGlobalConfig() {
            // (we should remove this concept).
            return GLOBAL_PROPERTY_PREFIX.equals(getBeanName());
        }

        public void add(AssignmentStatement as) {
            as.setBeanConfiguration(this);
            assignments.add(as);
        }

        /**
         * When this configuration is parsed sufficiently to create (or find) an actual bean instance, that instance
         * will be associated with its configuration by setting it via this method.
         *
         * @param bean the bean instantiated (or found) that corresponds to this BeanConfiguration instance.
         */
        public void setBean(Object bean) {
            this.bean = bean;
        }

        public Object getBean() {
            return this.bean;
        }

        /**
         * Returns true if all configuration statements have been executed.
         *
         * @return true if all configuration statements have been executed.
         */
        public boolean isExecuted() {
            if (instantiationStatement != null && !instantiationStatement.isExecuted()) {
                return false;
            }
            for (AssignmentStatement as : assignments) {
                if (!as.isExecuted()) {
                    return false;
                }
            }
            return true;
        }
    }

    private abstract class Statement {

        protected final String lhs;
        protected final String rhs;
        protected Object bean;
        private Object result;
        private boolean executed;
        private BeanConfiguration beanConfiguration;

        private Statement(String lhs, String rhs) {
            this.lhs = lhs;
            this.rhs = rhs;
            this.executed = false;
        }

        public void setBeanConfiguration(BeanConfiguration bd) {
            this.beanConfiguration = bd;
        }

        public BeanConfiguration getBeanConfiguration() {
            return this.beanConfiguration;
        }

        public Object execute() {
            if (!isExecuted()) {
                this.result = doExecute();
                this.executed = true;
            }
            if (!getBeanConfiguration().isGlobalConfig()) {
                Assert.notNull(this.bean, "Implementation must set the root bean for which it executed.");
            }
            return this.result;
        }

        public Object getBean() {
            return this.bean;
        }

        protected void setBean(Object bean) {
            this.bean = bean;
            if (this.beanConfiguration.getBean() == null) {
                this.beanConfiguration.setBean(bean);
            }
        }

        public Object getResult() {
            return result;
        }

        protected abstract Object doExecute();

        public boolean isExecuted() {
            return executed;
        }
    }

    private final class InstantiationStatement extends Statement {

        private InstantiationStatement(String lhs, String rhs) {
            super(lhs, rhs);
        }

        @Override
        protected Object doExecute() {
            String beanName = this.lhs;
            createNewInstance(objects, beanName, this.rhs);
            Object instantiated = objects.get(beanName);
            setBean(instantiated);

            //also ensure the instantiated bean has access to the event bus or is subscribed to events if necessary:
            //Note: because events are being enabled on this bean here (before the instantiated event below is
            //triggered), beans can react to their own instantiation events.
            enableEventsIfNecessary(instantiated, beanName);

            BeanEvent event = new InstantiatedBeanEvent(beanName, instantiated, Collections.unmodifiableMap(objects));
            eventBus.publish(event);

            return instantiated;
        }
    }

    private final class AssignmentStatement extends Statement {

        private final String rootBeanName;

        private AssignmentStatement(String lhs, String rhs) {
            super(lhs, rhs);
            int index = lhs.indexOf('.');
            this.rootBeanName = lhs.substring(0, index);
        }

        @Override
        protected Object doExecute() {
            applyProperty(lhs, rhs, objects);
            Object bean = objects.get(this.rootBeanName);
            setBean(bean);
            return null;
        }

        public String getRootBeanName() {
            return this.rootBeanName;
        }
    }

    //////////////////////////
    // From CollectionUtils //
    //////////////////////////
    // CollectionUtils cannot be removed from shiro-core until 2.0 as it has a dependency on PrincipalCollection

    private static boolean isEmpty(Map m) {
        return m == null || m.isEmpty();
    }

    private static boolean isEmpty(Collection c) {
        return c == null || c.isEmpty();
    }

}
