/*
 * 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.commons.chain2.impl;

import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.io.Serializable;
import java.lang.reflect.Method;
import java.util.AbstractCollection;
import java.util.AbstractSet;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;

import org.apache.commons.chain2.Context;
import org.apache.commons.chain2.config.ConfigUtil;
import org.apache.commons.logging.LogFactory;

/**
 * <p>Convenience base class for {@link Context} implementations.</p>
 *
 * <p>In addition to the minimal functionality required by the {@link Context}
 * interface, this class implements the recommended support for
 * <em>Attribute-Property Transparency</em>. This is implemented by
 * analyzing the available JavaBeans properties of this class (or its
 * subclass), exposes them as key-value pairs in the <code>Map</code>,
 * with the key being the name of the property itself.</p>
 *
 * <p><strong>IMPLEMENTATION NOTE</strong> - Because <code>empty</code> is a
 * read-only property defined by the <code>Map</code> interface, it may not
 * be utilized as an attribute key or property name.</p>
 *
 * @version $Id$
 */
public class ContextBase extends ContextMap<String, Object> {

    /** serialVersionUID */
    private static final long serialVersionUID = 20120724L;

    // ------------------------------------------------------ Instance Variables

    // NOTE - PropertyDescriptor instances are not Serializable, so the
    // following variables must be declared as transient.  When a ContextBase
    // instance is deserialized, the no-arguments constructor is called,
    // and the initialize() method called there will repoopulate them.
    // Therefore, no special restoration activity is required.

    /**
     * <p>Distinguished singleton value that is stored in the map for each
     * key that is actually a property.  This value is used to ensure that
     * <code>equals()</code> comparisons will always fail.</p>
     */
    private static final Object SINGLETON = new Serializable() {
        private static final long serialVersionUID = 20120724L;
    };

    /**
     * <p>Zero-length array of parameter values for calling property getters.
     * </p>
     */
    private static Object[] zeroParams = {};

    /**
     * <p>The <code>PropertyDescriptor</code>s for all JavaBeans properties
     * of this {@link Context} implementation class, keyed by property name.
     * This collection is allocated only if there are any JavaBeans
     * properties.</p>
     */
    private transient Map<String, PropertyDescriptor> descriptors = null;

    /**
     * <p>The same <code>PropertyDescriptor</code>s as an array.</p>
     */
    private transient PropertyDescriptor[] pd = null;

    // ------------------------------------------------------------ Constructors

    /**
     * Default, no argument constructor.
     */
    public ContextBase() {
        super();
        initialize();
    }

    /**
     * <p>Initialize the contents of this {@link Context} by copying the
     * values from the specified <code>Map</code>.  Any keys in <code>map</code>
     * that correspond to local properties will cause the setter method for
     * that property to be called.</p>
     *
     * @param map Map whose key-value pairs are added
     *
     * @exception IllegalArgumentException if an exception is thrown
     *  writing a local property value
     * @exception UnsupportedOperationException if a local property does not
     *  have a write method.
     */
    public ContextBase(final Map<? extends String, ? extends Object> map) {
        super(map);
        initialize();
        putAll(map);
    }

    // ------------------------------------------------------------- Map Methods

    /**
     * <p>Override the default <code>Map</code> behavior to clear all keys and
     * values except those corresponding to JavaBeans properties.</p>
     */
    @Override
    public void clear() {
        if (this.descriptors == null) {
            super.clear();
        } else {
            Set<String> set = new HashSet<>(super.keySet());
            set.removeAll(this.descriptors.keySet());
            set.stream().forEach(k -> super.remove(k));
        }
    }

    /**
     * <p>Override the default <code>Map</code> behavior to return
     * <code>true</code> if the specified value is present in either the
     * underlying <code>Map</code> or one of the local property values.</p>
     *
     * @param value the value look for in the context.
     * @return <code>true</code> if found in this context otherwise
     *  <code>false</code>.
     * @exception IllegalArgumentException if a property getter
     *  throws an exception
     */
    @Override
    public boolean containsValue(final Object value) {
        if (value != null && this.descriptors == null) {
            // Case 1 -- no local properties
            return super.containsValue(value);
        } else if (value != null && super.containsValue(value)) {
            // Case 2 -- value found in the underlying Map
            return true;
        }

        // Case 3 -- check the values of our readable properties
        for (final PropertyDescriptor apd : this.pd) {
            if (apd.getReadMethod() != null) {
                Object prop = readProperty(apd);
                if ((value == null && prop == null)
                        || (value != null && value.equals(prop))) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * <p>Override the default <code>Map</code> behavior to return a
     * <code>Set</code> that meets the specified default behavior except
     * for attempts to remove the key for a property of the {@link Context}
     * implementation class, which will throw
     * <code>UnsupportedOperationException</code>.</p>
     *
     * @return Set of entries in the Context.
     */
    @Override
    public Set<Entry<String, Object>> entrySet() {
        return new EntrySetImpl();
    }

    /**
     * <p>Override the default <code>Map</code> behavior to return the value
     * of a local property if the specified key matches a local property name.
     * </p>
     *
     * <p><strong>IMPLEMENTATION NOTE</strong> - If the specified
     * <code>key</code> identifies a write-only property, <code>null</code>
     * will arbitrarily be returned, in order to avoid difficulties implementing
     * the contracts of the <code>Map</code> interface.</p>
     *
     * @param key Key of the value to be returned
     * @return The value for the specified key.
     *
     * @exception IllegalArgumentException if an exception is thrown
     *  reading this local property value
     * @exception UnsupportedOperationException if this local property does not
     *  have a read method.
     */
    @Override
    public Object get(final Object key) {
        if (key != null) {
            if (this.descriptors != null) {
                // Case 2 -- this is a local property
                PropertyDescriptor descriptor = this.descriptors.get(key);
                if (descriptor != null) {
                    if (descriptor.getReadMethod() != null) {
                        return readProperty(descriptor);
                    }
                    return null;
                }
            }
            // Case 1 -- no local properties
            // Case 3 -- retrieve value from our underlying Map
            return super.get(key);
        }
        return null;
    }

    /**
     * <p>Override the default <code>Map</code> behavior to return
     * <code>true</code> if the underlying <code>Map</code> only contains
     * key-value pairs for local properties (if any).</p>
     *
     * @return <code>true</code> if this Context is empty, otherwise
     *  <code>false</code>.
     */
    @Override
    public boolean isEmpty() {
        // Case 1 -- no local properties
        if (this.descriptors == null) {
            return super.isEmpty();
        }

        // Case 2 -- compare key count to property count
        return super.size() <= this.descriptors.size();
    }

    /**
     * <p>Override the default <code>Map</code> behavior to set the value
     * of a local property if the specified key matches a local property name.
     * </p>
     *
     * @param key Key of the value to be stored or replaced
     * @param value New value to be stored
     * @return The value added to the Context.
     *
     * @exception IllegalArgumentException if an exception is thrown
     *  reading or writing this local property value
     * @exception UnsupportedOperationException if this local property does not
     *  have both a read method and a write method
     */
    @Override
    public Object put(final String key, final Object value) {
        // ConcurrentHashMap doesn't accept null values, see
        if (value == null && this.descriptors == null) {
            if (super.containsKey(key)) {
                super.remove(key);
            }
        } else {
            if (key != null) {
                // Case 1 -- no local properties
                if (value != null && this.descriptors == null) {
                    return super.put(key, value);
                }

                // Case 2 -- this is a local property
                PropertyDescriptor descriptor = this.descriptors.get(key);
                if (descriptor != null) {
                    Object previous = null;
                    if (descriptor.getReadMethod() != null) {
                        previous = readProperty(descriptor);
                    }
                    writeProperty(descriptor, value);
                    return previous;
                }

                // Case 3 -- store or replace value in our underlying map
                if (value != null) {
                    return super.put(key, value);
                }
            }
        }
        return null;
    }

    /**
     * <p>Override the default <code>Map</code> behavior to call the
     * <code>put()</code> method individually for each key-value pair
     * in the specified <code>Map</code>.</p>
     *
     * @param map <code>Map</code> containing key-value pairs to store
     *  (or replace)
     *
     * @exception IllegalArgumentException if an exception is thrown
     *  reading or writing a local property value
     * @exception UnsupportedOperationException if a local property does not
     *  have both a read method and a write method
     */
    @Override
    public void putAll(final Map<? extends String, ? extends Object> map) {
        for (final Entry<? extends String, ? extends Object> pair : map.entrySet()) {
            put(pair.getKey(), pair.getValue());
        }
    }

    /**
     * <p>Override the default <code>Map</code> behavior to throw
     * <code>UnsupportedOperationException</code> on any attempt to
     * remove a key that is the name of a local property.</p>
     *
     * @param key Key to be removed
     * @return The value removed from the Context.
     *
     * @exception UnsupportedOperationException if the specified
     *  <code>key</code> matches the name of a local property
     */
    @Override
    public Object remove(final Object key) {
        if (key != null) {
            if (this.descriptors != null) {
                // Case 2 -- this is a local property
                PropertyDescriptor descriptor = this.descriptors.get(key);
                if (descriptor != null) {
                    throw new UnsupportedOperationException(
                            String.format("Local property '%s' cannot be removed", key));
                }
            }
            // Case 1 -- no local properties
            // Case 3 -- remove from underlying Map
            return super.remove(key);
        }
        return null;
    }

    /**
     * <p>Override the default <code>Map</code> behavior to return a
     * <code>Collection</code> that meets the specified default behavior except
     * for attempts to remove the key for a property of the {@link Context}
     * implementation class, which will throw
     * <code>UnsupportedOperationException</code>.</p>
     *
     * @return The collection of values in this Context.
     */
    @Override
    public Collection<Object> values() {
        return new ValuesImpl();
    }

    // --------------------------------------------------------- Private Methods
    /**
     * <p>Return an <code>Iterator</code> over the set of <code>Map.Entry</code>
     * objects representing our key-value pairs.</p>
     * @return Iterator
     */
    protected Iterator<Entry<String, Object>> entriesIterator() {
        return new EntrySetIterator();
    }

    /**
     * <p>Return a <code>Map.Entry</code> for the specified key value, if it
     * is present; otherwise, return <code>null</code>.</p>
     * @param key Attribute key or property name
     * @return Map.Entry
     */
    protected Map.Entry<String, Object> entry(final String key) {
        if (containsKey(key)) {
            return new MapEntryImpl(key, get(key));
        }
        return null;
    }

    /**
     * <p>Customize the contents of our underlying <code>Map</code> so that
     * it contains keys corresponding to all of the JavaBeans properties of
     * the {@link Context} implementation class.</p>
     *
     *
     * @exception IllegalArgumentException if an exception is thrown
     *  writing this local property value
     * @exception UnsupportedOperationException if this local property does not
     *  have a write method.
     */
    private void initialize() {
        // Retrieve the set of property descriptors for this Context class
        try {
            this.pd = Introspector.getBeanInfo(getClass()).getPropertyDescriptors();
        } catch (final IntrospectionException e) {
            LogFactory.getLog(ContextBase.class).warn(e.getMessage());
            // Should never happen
            this.pd = new PropertyDescriptor[0];
        }

        // Initialize the underlying Map contents
        for (final PropertyDescriptor propertyDescriptor : this.pd) {
            String name = propertyDescriptor.getName();

            // Add descriptor (ignoring getClass() and isEmpty())
            if (!("class".equals(name) || "empty".equals(name))) {
                if (this.descriptors == null) {
                    this.descriptors = new HashMap<>(this.pd.length - 2);
                }
                this.descriptors.put(name, propertyDescriptor);
                super.put(name, SINGLETON);
            }
        }
    }

    /**
     * <p>Get and return the value for the specified property.</p>
     *
     * @param descriptor <code>PropertyDescriptor</code> for the
     *  specified property
     * @return Object
     * @exception IllegalArgumentException if an exception is thrown
     *  reading this local property value
     * @exception UnsupportedOperationException if this local property does not
     *  have a read method.
     */
    private Object readProperty(final PropertyDescriptor descriptor) {
        Method method = descriptor.getReadMethod();
        if (method == null) {
            throw new UnsupportedOperationException(
                    String.format("Property '%s' is not readable", descriptor.getName()));
        }
        try {
            return method.invoke(this, zeroParams);
        } catch (final ReflectiveOperationException e) {
            throw new UnsupportedOperationException(
                    String.format("Exception reading property '%s': %s",
                            descriptor.getName(), e.getMessage()));
        }
    }

    /**
     * <p>Remove the specified key-value pair, if it exists, and return
     * <code>true</code>.  If this pair does not exist, return
     * <code>false</code>.</p>
     *
     * @param entry Key-value pair to be removed
     * @return boolean
     * @exception UnsupportedOperationException if the specified key
     *  identifies a property instead of an attribute
     */
    protected boolean remove(final Map.Entry<String, Object> entry) {
        Map.Entry<String, Object> actual = entry(entry.getKey());
        if (actual == null) {
            return false;
        } else if (!entry.equals(actual)) {
            return false;
        } else {
            remove(entry.getKey());
            return true;
        }
    }

    /**
     * <p>Return an <code>Iterator</code> over the set of values in this <code>Map</code>.</p>
     * @return Iterator
     */
    protected Iterator<Object> valuesIterator() {
        return new ValuesIterator();
    }

    /**
     * <p>Set the value for the specified property.</p>
     *
     * @param descriptor <code>PropertyDescriptor</code> for the
     *  specified property
     * @param value The new value for this property (must be of the
     *  correct type)
     *
     * @exception IllegalArgumentException if an exception is thrown
     *  writing this local property value
     * @exception UnsupportedOperationException if this local property does not
     *  have a write method.
     */
    private void writeProperty(final PropertyDescriptor descriptor, final Object value) {
        Method method = descriptor.getWriteMethod();
        if (method == null) {
            throw new UnsupportedOperationException(
                    String.format("Property '%s' is not writeable", descriptor.getName()));
        }
        try {
            method.invoke(this, value);
        } catch (final ReflectiveOperationException e) {
            throw new UnsupportedOperationException(
                    String.format("Exception writing property '%s': %s",
                            descriptor.getName(), e.getMessage()));
        }
    }

    /**
     * @see java.util.concurrent.ConcurrentHashMap#containsKey(java.lang.Object)
     */
    @Override
    public boolean containsKey(final Object key) {
        return (this.descriptors != null && this.descriptors.containsKey(key))
                || super.containsKey(key);
    }

    /**
     * @see java.util.concurrent.ConcurrentHashMap#hashCode()
     */
    @Override
    public int hashCode() {
        return super.hashCode() + 0;
    }

    /**
     * @see java.util.concurrent.ConcurrentHashMap#equals(java.lang.Object)
     */
    @Override
    public boolean equals(final Object o) {
        if (o != this) {
            if (!(o instanceof Map)) {
                return false;
            }

            Map<?, ?> m = (Map<?, ?>) o;
            if (m.size() != size()) {
                return false;
            }

            for (final Map.Entry<?, ?> e : m.entrySet()) {
                Object mk = e.getKey();
                if (mk == null) {
                    return false;
                }
                Object mv = m.get(mk);
                Object v = get(mk);
                if (mv != v && (mv == null || !mv.equals(v))) {
                    return false;
                }
            }
        }
        return true;
    }

    // --------------------------------------------------------- Private Classes

    /**
     * <p>Private implementation of <code>Set</code> that implements the
     * semantics required for the value returned by <code>entrySet()</code>.</p>
     */
    private final class EntrySetImpl extends AbstractSet<Entry<String, Object>> {

        /**
         * constructor
         */
        EntrySetImpl() {
            super();
        }

        /**
         * @see java.util.AbstractCollection#clear()
         */
        @Override
        public void clear() {
            ContextBase.this.clear();
        }

        /**
         * @see java.util.AbstractCollection#contains(java.lang.Object)
         */
        @Override
        public boolean contains(final Object obj) {
            if (obj instanceof Map.Entry) {
                /* The contains method is expecting the search type to be of the
                 * same type stored. This contract is enforced as a precondition.
                 * So we can safely suppress type safety warnings below. */
                Map.Entry<String, Object> entry = ConfigUtil.cast(obj);
                Entry<String, Object> actual = ContextBase.this.entry(entry.getKey());
                if (actual != null) {
                    return actual.equals(entry);
                }
            }
            return false;
        }

        /**
         * @see java.util.AbstractCollection#isEmpty()
         */
        @Override
        public boolean isEmpty() {
            return ContextBase.this.isEmpty();
        }

        /**
         * @see java.util.AbstractCollection#iterator()
         */
        @Override
        public Iterator<Entry<String, Object>> iterator() {
            return ContextBase.this.entriesIterator();
        }

        /**
         * @see java.util.AbstractCollection#remove(java.lang.Object)
         */
        @Override
        public boolean remove(final Object obj) {
            if (obj instanceof Map.Entry) {
                /* The remove method is expecting an input of the the same
                 * type as the entry set. This precondition is checked above,
                 * so we can safely suppress the unchecked warnings. */
                Map.Entry<String, Object> entry = ConfigUtil.cast(obj);
                return ContextBase.this.remove(entry);
            }
            return false;
        }

        /**
         * @see java.util.AbstractCollection#size()
         */
        @Override
        public int size() {
            return ContextBase.this.size();
        }
    }

    /**
     * <p>Private implementation of <code>Iterator</code> for the
     * <code>Set</code> returned by <code>entrySet()</code>.</p>
     */
    private final class EntrySetIterator implements Iterator<Entry<String, Object>> {

        private Map.Entry<String, Object> entry = null;
        private Iterator<String> keys = ContextBase.this.keySet().iterator();

        /**
         * constructor
         */
        EntrySetIterator() {
            super();
        }

        /**
         * @see java.util.Iterator#hasNext()
         */
        @Override
        public boolean hasNext() {
            return this.keys.hasNext();
        }

        /**
         * @see java.util.Iterator#next()
         */
        @Override
        public Entry<String, Object> next() {
            this.entry = ContextBase.this.entry(this.keys.next());
            return this.entry;
        }

        /**
         * @see java.util.Iterator#remove()
         */
        @Override
        public void remove() {
            ContextBase.this.remove(this.entry);
        }
    }

    /**
     * <p>Private implementation of <code>Map.Entry</code> for each item in
     * <code>EntrySetImpl</code>.</p>
     */
    private final class MapEntryImpl implements Map.Entry<String, Object> {

        private String key;
        private Object value;

        /**
         * @param k Key
         * @param v Value
         */
        MapEntryImpl(final String k, final Object v) {
            this.key = k;
            this.value = v;
        }

        /**
         * @see java.lang.Object#equals(java.lang.Object)
         */
        @Override
        public boolean equals(final Object obj) {
            if (obj instanceof Map.Entry) {
                Map.Entry<?, ?> entry = (Map.Entry<?, ?>) obj;
                if (this.key == null) {
                    return entry.getKey() == null;
                }
                if (this.key.equals(entry.getKey())) {
                    if (this.value == null) {
                        return entry.getValue() == null;
                    }
                    return this.value.equals(entry.getValue());
                }
            }
            return false;
        }

        /**
         * @see java.util.Map.Entry#getKey()
         */
        @Override
        public String getKey() {
            return this.key;
        }

        /**
         * @see java.util.Map.Entry#getValue()
         */
        @Override
        public Object getValue() {
            return this.value;
        }

        /**
         * @see java.lang.Object#hashCode()
         */
        @Override
        public int hashCode() {
            return (this.key == null ? 0 : this.key.hashCode())
                   ^ (this.value == null ? 0 : this.value.hashCode());
        }

        /**
         * @see java.util.Map.Entry#setValue(java.lang.Object)
         */
        @Override
        public Object setValue(final Object v) {
            Object previous = this.value;
            ContextBase.this.put(this.key, v);
            this.value = v;
            return previous;
        }

        /**
         * @see java.lang.Object#toString()
         */
        @Override
        public String toString() {
            return getKey() + "=" + getValue();
        }
    }

    /**
     * <p>Private implementation of <code>Collection</code> that implements the
     * semantics required for the value returned by <code>values()</code>.</p>
     */
    private final class ValuesImpl extends AbstractCollection<Object> {

        /**
         * constructor
         */
        ValuesImpl() {
            super();
        }

        /**
         * @see java.util.AbstractCollection#clear()
         */
        @Override
        public void clear() {
            ContextBase.this.clear();
        }

        /**
         * @see java.util.AbstractCollection#contains(java.lang.Object)
         */
        @Override
        public boolean contains(final Object obj) {
            if (!(obj instanceof Map.Entry)) {
                return false;
            }
            Map.Entry<?, ?> entry = (Map.Entry<?, ?>) obj;
            return ContextBase.this.containsValue(entry.getValue());
        }

        /**
         * @see java.util.AbstractCollection#isEmpty()
         */
        @Override
        public boolean isEmpty() {
            return ContextBase.this.isEmpty();
        }

        /**
         * @see java.util.AbstractCollection#iterator()
         */
        @Override
        public Iterator<Object> iterator() {
            return ContextBase.this.valuesIterator();
        }

        /**
         * @see java.util.AbstractCollection#remove(java.lang.Object)
         */
        @Override
        public boolean remove(final Object obj) {
            if (obj instanceof Map.Entry) {
                /* We are expecting the passed entry to be of a type
                 * Entry<String, Object>. This is checked in the precondition
                 * above, so we can safely suppress unchecked warnings. */
                Map.Entry<String, Object> entry = ConfigUtil.cast(obj);
                return ContextBase.this.remove(entry);
            }
            return false;
        }

        /**
         * @see java.util.AbstractCollection#size()
         */
        @Override
        public int size() {
            return ContextBase.this.size();
        }
    }

    /**
     * <p>Private implementation of <code>Iterator</code> for the
     * <code>Collection</code> returned by <code>values()</code>.</p>
     */
    private final class ValuesIterator implements Iterator<Object> {

        private Map.Entry<String, Object> entry;
        private Iterator<String> keys = ContextBase.this.keySet().iterator();

        /**
         * constructor
         */
        ValuesIterator() {
            super();
        }

        /**
         * @see java.util.Iterator#hasNext()
         */
        @Override
        public boolean hasNext() {
            return this.keys.hasNext();
        }

        /**
         * @see java.util.Iterator#next()
         */
        @Override
        public Object next() {
            this.entry = ContextBase.this.entry(this.keys.next());
            return this.entry.getValue();
        }

        /**
         * @see java.util.Iterator#remove()
         */
        @Override
        public void remove() {
            ContextBase.this.remove(this.entry);
        }
    }
}
