/**
 * 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.camel.util.toolbox;

import java.util.Collection;

import org.apache.camel.Exchange;
import org.apache.camel.Expression;
import org.apache.camel.Predicate;
import org.apache.camel.TypeConversionException;
import org.apache.camel.builder.ExpressionBuilder;
import org.apache.camel.processor.aggregate.AggregationStrategy;
import org.apache.camel.processor.aggregate.CompletionAwareAggregationStrategy;
import org.apache.camel.processor.aggregate.TimeoutAwareAggregationStrategy;
import org.apache.camel.util.ExchangeHelper;
import org.apache.camel.util.ObjectHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * The Flexible Aggregation Strategy is a highly customizable, fluently configurable aggregation strategy. It allows you to quickly whip up an {@link AggregationStrategy} 
 * that is capable of performing the most typical aggregation duties, with zero Java code.
 * <p/>
 * It can perform the following logic:
 * <ul>
 *   <li>Filtering results based on a defined {@link Predicate} written in any language, such as XPath, OGNL, Simple, Javascript, etc.</li>
 *   <li>Picking specific data elements for aggregation.</li>
 *   <li>Accumulating results in any designated {@link Collection} type, e.g. in a HashSet, LinkedList, ArrayList, etc.</li>
 *   <li>Storing the output in a specific place in the Exchange: a property, a header or in the body.</li>
 * </ul>
 * 
 * It also includes the ability to specify both aggregation batch completion actions and timeout actions, in an abbreviated manner.
 * <p/>
 * This Aggregation Strategy is suitable for usage in aggregate, split, multicast, enrich and recipient list EIPs.
 * 
 */
public class FlexibleAggregationStrategy<E extends Object> implements AggregationStrategy, 
        CompletionAwareAggregationStrategy, TimeoutAwareAggregationStrategy {

    private static final Logger LOG = LoggerFactory.getLogger(FlexibleAggregationStrategy.class);

    private Expression pickExpression = ExpressionBuilder.bodyExpression();
    private Predicate conditionPredicate;
    @SuppressWarnings("rawtypes")
    private Class<? extends Collection> collectionType;
    @SuppressWarnings("unchecked")
    private Class<E> castAs = (Class<E>) Object.class;
    private boolean storeNulls;
    private boolean ignoreInvalidCasts; // = false
    private FlexibleAggregationStrategyInjector injector = new BodyInjector(castAs);
    private TimeoutAwareMixin timeoutMixin;
    private CompletionAwareMixin completionMixin;

    /**
     * Initializes a new instance with {@link Object} as the {@link FlexibleAggregationStrategy#castAs} type.
     */
    public FlexibleAggregationStrategy() {
    }
    
    /**
     * Initializes a new instance with the specified type as the {@link FlexibleAggregationStrategy#castAs} type.
     * @param type The castAs type.
     */
    public FlexibleAggregationStrategy(Class<E> type) {
        this.castAs = type;
    }
    
    /**
     * Set an expression to extract the element to be aggregated from the incoming {@link Exchange}.
     * All results are cast to the {@link FlexibleAggregationStrategy#castAs} type (or the type specified in the constructor).
     * <p/>
     * By default, it picks the full IN message body of the incoming exchange. 
     * @param expression The picking expression.
     * @return This instance.
     */
    public FlexibleAggregationStrategy<E> pick(Expression expression) {
        this.pickExpression = expression;
        return this;
    }

    /**
     * Set a filter condition such as only results satisfying it will be aggregated. 
     * By default, all picked values will be processed.
     * @param predicate The condition.
     * @return This instance.
     */
    public FlexibleAggregationStrategy<E> condition(Predicate predicate) {
        this.conditionPredicate = predicate;
        return this;
    }

    /**
     * Accumulate the result of the <i>pick expression</i> in a collection of the designated type. 
     * No <tt>null</tt>s will stored unless the {@link FlexibleAggregationStrategy#storeNulls()} option is enabled.
     * @param collectionType The type of the Collection to aggregate into.
     * @return This instance.
     */
    @SuppressWarnings("rawtypes")
    public FlexibleAggregationStrategy<E> accumulateInCollection(Class<? extends Collection> collectionType) {
        this.collectionType = collectionType;
        return this;
    }

    /**
     * Store the result of this Aggregation Strategy (whether an atomic element or a Collection) in a property with
     * the designated name.
     * @param propertyName The property name.
     * @return This instance.
     */
    public FlexibleAggregationStrategy<E> storeInProperty(String propertyName) {
        this.injector = new PropertyInjector(castAs, propertyName);
        return this;
    }

    /**
     * Store the result of this Aggregation Strategy (whether an atomic element or a Collection) in an IN message header with
     * the designated name.
     * @param headerName The header name.
     * @return This instance.
     */
    public FlexibleAggregationStrategy<E> storeInHeader(String headerName) {
        this.injector = new HeaderInjector(castAs, headerName);
        return this;
    }

    /**
     * Store the result of this Aggregation Strategy (whether an atomic element or a Collection) in the body of the IN message.
     * @return This instance.
     */
    public FlexibleAggregationStrategy<E> storeInBody() {
        this.injector = new BodyInjector(castAs);
        return this;
    }

    /**
     * Cast the result of the <i>pick expression</i> to this type.
     * @param castAs Type for the cast.
     * @return This instance.
     */
    public FlexibleAggregationStrategy<E> castAs(Class<E> castAs) {
        this.castAs = castAs;
        injector.setType(castAs);
        return this;
    }

    /**
     * Enables storing null values in the resulting collection.
     * By default, this aggregation strategy will drop null values.
     * @return This instance.
     */
    public FlexibleAggregationStrategy<E> storeNulls() {
        this.storeNulls = true;
        return this;
    }
    
    /**
     * Ignores invalid casts instead of throwing an exception if the <i>pick expression</i> result cannot be casted to the 
     * specified type.
     * By default, this aggregation strategy will throw an exception if an invalid cast occurs.
     * @return This instance.
     */
    public FlexibleAggregationStrategy<E> ignoreInvalidCasts() {
        this.ignoreInvalidCasts = true;
        return this;
    }
    
    /**
     * Plugs in logic to execute when a timeout occurs.
     * @param timeoutMixin
     * @return This instance.
     */
    public FlexibleAggregationStrategy<E> timeoutAware(TimeoutAwareMixin timeoutMixin) {
        this.timeoutMixin = timeoutMixin;
        return this;
    }

    /**
     * Plugs in logic to execute when an aggregation batch completes.
     * @param completionMixin
     * @return This instance.
     */
    public FlexibleAggregationStrategy<E> completionAware(CompletionAwareMixin completionMixin) {
        this.completionMixin = completionMixin;
        return this;
    }
    
    @Override
    public Exchange aggregate(Exchange oldExchange, Exchange newExchange) {
        Exchange exchange = oldExchange;
        if (exchange == null) {
            exchange = ExchangeHelper.createCorrelatedCopy(newExchange, true);
            injector.prepareAggregationExchange(exchange);
        }

        // 1. Apply the condition and reject the aggregation if unmatched
        if (conditionPredicate != null && !conditionPredicate.matches(newExchange)) {
            LOG.trace("Dropped exchange {} from aggregation as predicate {} was not matched", newExchange, conditionPredicate);
            return exchange;
        }

        // 2. Pick the appropriate element of the incoming message, casting it to the specified class
        //    If null, act accordingly based on storeNulls
        E picked = null;
        try {
            picked = pickExpression.evaluate(newExchange, castAs);
        } catch (TypeConversionException exception) {
            if (!ignoreInvalidCasts) {
                throw exception;
            }
        }
        
        if (picked == null && !storeNulls) {
            LOG.trace("Dropped exchange {} from aggregation as pick expression returned null and storing nulls is not enabled", newExchange);
            return exchange;
        }

        if (collectionType == null) {
            injectAsRawValue(exchange, picked);
        } else {
            injectAsCollection(exchange, picked, collectionType);
        }

        return exchange;
    }
    

    @Override
    public void timeout(Exchange oldExchange, int index, int total, long timeout) {
        if (timeoutMixin == null) {
            return;
        }
        timeoutMixin.timeout(oldExchange, index, total, timeout);
    }

    @Override
    public void onCompletion(Exchange exchange) {
        if (completionMixin == null) {
            return;
        }
        completionMixin.onCompletion(exchange);
    }

    private void injectAsRawValue(Exchange oldExchange, E picked) {
        injector.setValue(oldExchange, picked);
    }

    private void injectAsCollection(Exchange oldExchange, E picked, Class<? extends Collection> collectionType) {
        Collection<E> col = injector.getValueAsCollection(oldExchange, collectionType);
        col = safeInsertIntoCollection(oldExchange, col, picked);
        injector.setValueAsCollection(oldExchange, col);
    }

    @SuppressWarnings("unchecked")
    private Collection<E> safeInsertIntoCollection(Exchange oldExchange, Collection<E> oldValue, E toInsert) {
        Collection<E> collection = null;
        try {
            if (oldValue == null || oldExchange.getProperty(Exchange.AGGREGATED_COLLECTION_GUARD, Boolean.class) == null) {
                try {
                    collection = collectionType.newInstance();
                } catch (Exception e) {
                    LOG.warn("Could not instantiate collection of type {}. Aborting aggregation.", collectionType);
                    throw ObjectHelper.wrapCamelExecutionException(oldExchange, e);
                }
                oldExchange.setProperty(Exchange.AGGREGATED_COLLECTION_GUARD, Boolean.FALSE);
            } else {
                collection = collectionType.cast(oldValue);
            }
            
            if (collection != null) {
                collection.add(toInsert);
            }
            
        } catch (ClassCastException exception) {
            if (!ignoreInvalidCasts) {
                throw exception;
            }
        }
        return collection;
    }
    
    public interface TimeoutAwareMixin {
        void timeout(Exchange exchange, int index, int total, long timeout);
    }
    
    public interface CompletionAwareMixin {
        void onCompletion(Exchange exchange);
    }
    
    private abstract class FlexibleAggregationStrategyInjector {
        protected Class<E> type;
        
        FlexibleAggregationStrategyInjector(Class<E> type) {
            this.type = type;
        }
        
        public void setType(Class<E> type) {
            this.type = type;
        }
        
        public abstract void prepareAggregationExchange(Exchange exchange);
        public abstract E getValue(Exchange exchange);
        public abstract void setValue(Exchange exchange, E obj);
        public abstract Collection<E> getValueAsCollection(Exchange exchange, Class<? extends Collection> type);
        public abstract void setValueAsCollection(Exchange exchange, Collection<E> obj);
    }
    
    private class PropertyInjector extends FlexibleAggregationStrategyInjector {
        private String propertyName;
        
        PropertyInjector(Class<E> type, String propertyName) {
            super(type);
            this.propertyName = propertyName;
        }
        
        @Override
        public void prepareAggregationExchange(Exchange exchange) {
            exchange.removeProperty(propertyName);
        }
        
        @Override
        public E getValue(Exchange exchange) {
            return exchange.getProperty(propertyName, type);
        }

        @Override
        public void setValue(Exchange exchange, E obj) {
            exchange.setProperty(propertyName, obj);
        }

        @Override @SuppressWarnings("unchecked")
        public Collection<E> getValueAsCollection(Exchange exchange, Class<? extends Collection> type) {
            Object value = exchange.getProperty(propertyName);
            if (value == null) {
                // empty so create a new collection to host this
                return exchange.getContext().getInjector().newInstance(type);
            } else {
                return exchange.getProperty(propertyName, type);
            }
        }

        @Override
        public void setValueAsCollection(Exchange exchange, Collection<E> obj) {
            exchange.setProperty(propertyName, obj);
        }

    }
    
    private class HeaderInjector extends FlexibleAggregationStrategyInjector {
        private String headerName;
        
        HeaderInjector(Class<E> type, String headerName) {
            super(type);
            this.headerName = headerName;
        }
        
        @Override
        public void prepareAggregationExchange(Exchange exchange) {
            exchange.getIn().removeHeader(headerName);
        }
        
        @Override
        public E getValue(Exchange exchange) {
            return exchange.getIn().getHeader(headerName, type);
        }

        @Override
        public void setValue(Exchange exchange, E obj) {
            exchange.getIn().setHeader(headerName, obj);
        }

        @Override @SuppressWarnings("unchecked")
        public Collection<E> getValueAsCollection(Exchange exchange, Class<? extends Collection> type) {
            Object value = exchange.getIn().getHeader(headerName);
            if (value == null) {
                // empty so create a new collection to host this
                return exchange.getContext().getInjector().newInstance(type);
            } else {
                return exchange.getIn().getHeader(headerName, type);
            }
        }
        
        @Override
        public void setValueAsCollection(Exchange exchange, Collection<E> obj) {
            exchange.getIn().setHeader(headerName, obj);
        }
    }
    
    private class BodyInjector extends FlexibleAggregationStrategyInjector {
        BodyInjector(Class<E> type) {
            super(type);
        }

        @Override
        public void prepareAggregationExchange(Exchange exchange) {
            exchange.getIn().setBody(null);
        }
        
        @Override
        public E getValue(Exchange exchange) {
            return exchange.getIn().getBody(type);
        }

        @Override
        public void setValue(Exchange exchange, E obj) {
            exchange.getIn().setBody(obj);
        }

        @Override @SuppressWarnings("unchecked")
        public Collection<E> getValueAsCollection(Exchange exchange, Class<? extends Collection> type) {
            Object value = exchange.getIn().getBody();
            if (value == null) {
                // empty so create a new collection to host this
                return exchange.getContext().getInjector().newInstance(type);
            } else {
                return exchange.getIn().getBody(type);
            }
        }
        
        @Override
        public void setValueAsCollection(Exchange exchange, Collection<E> obj) {
            exchange.getIn().setBody(obj);
        }
    }
    
}
