/**
 * 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.component.jpa;

import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Queue;
import javax.persistence.Entity;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.LockModeType;
import javax.persistence.OptimisticLockException;
import javax.persistence.PersistenceException;
import javax.persistence.PessimisticLockException;
import javax.persistence.Query;

import org.apache.camel.Exchange;
import org.apache.camel.Processor;
import org.apache.camel.impl.ScheduledBatchPollingConsumer;
import org.apache.camel.util.CastUtils;
import org.apache.camel.util.ObjectHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;

/**
 * @version 
 */
public class JpaConsumer extends ScheduledBatchPollingConsumer {
    private static final Logger LOG = LoggerFactory.getLogger(JpaConsumer.class);
    private static final Map<String, Object> NOWAIT;
    private final EntityManagerFactory entityManagerFactory;
    private final TransactionTemplate transactionTemplate;
    private EntityManager entityManager;
    private QueryFactory queryFactory;
    private DeleteHandler<Object> deleteHandler;
    private DeleteHandler<Object> preDeleteHandler;
    private String query;
    private String namedQuery;
    private String nativeQuery;
    private LockModeType lockModeType = LockModeType.PESSIMISTIC_WRITE;
    private Map<String, Object> parameters;
    private Class<?> resultClass;
    private boolean transacted;
    private boolean skipLockedEntity;

    static {
        NOWAIT = new HashMap<String, Object>();
        NOWAIT.put("javax.persistence.lock.timeout", 0L);
    }

    private static final class DataHolder {
        private Exchange exchange;
        private Object result;
        private EntityManager manager;
        private DataHolder() {
        }
    }

    public JpaConsumer(JpaEndpoint endpoint, Processor processor) {
        super(endpoint, processor);
        this.entityManagerFactory = endpoint.getEntityManagerFactory();
        this.transactionTemplate = endpoint.createTransactionTemplate();
    }

    @Override
    protected int poll() throws Exception {
        // must reset for each poll
        shutdownRunningTask = null;
        pendingExchanges = 0;
        
        // Recreate EntityManager in case it is disposed due to transaction rollback
        if (entityManager == null) {
            entityManager = entityManagerFactory.createEntityManager();
            LOG.trace("Recreated EntityManager {} on {}", entityManager, this);
        }

        Object messagePolled = transactionTemplate.execute(new TransactionCallback<Object>() {
            public Object doInTransaction(TransactionStatus status) {
                if (getEndpoint().isJoinTransaction()) {
                    entityManager.joinTransaction();
                }

                Queue<DataHolder> answer = new LinkedList<DataHolder>();

                Query query = getQueryFactory().createQuery(entityManager);
                configureParameters(query);
                LOG.trace("Created query {}", query);

                List<?> results = query.getResultList();
                LOG.trace("Got result list from query {}", results);

                for (Object result : results) {
                    DataHolder holder = new DataHolder();
                    holder.manager = entityManager;
                    holder.result = result;
                    holder.exchange = createExchange(result);
                    answer.add(holder);
                }

                PersistenceException cause = null;
                int messagePolled = 0;
                try {
                    messagePolled = processBatch(CastUtils.cast(answer));
                } catch (Exception e) {
                    if (e instanceof PersistenceException) {
                        cause = (PersistenceException) e;
                    } else {
                        cause = new PersistenceException(e);
                    }
                }

                if (cause != null) {
                    if (!isTransacted()) {
                        LOG.warn("Error processing last message due: {}. Will commit all previous successful processed message, and ignore this last failure.", cause.getMessage(), cause);
                    } else {
                        // Potentially EntityManager could be in an inconsistent state after transaction rollback,
                        // so disposing it to have it recreated in next poll. cf. Java Persistence API 3.3.2 Transaction Rollback
                        LOG.info("Disposing EntityManager {} on {} due to coming transaction rollback", entityManager, this);
                        entityManager.close();
                        entityManager = null;
                        
                        // rollback all by throwning exception
                        throw cause;
                    }
                }

                // commit
                LOG.debug("Flushing EntityManager");
                entityManager.flush();
                // must clear after flush
                entityManager.clear();
                return messagePolled;
            }
        });

        return getEndpoint().getCamelContext().getTypeConverter().convertTo(int.class, messagePolled);
    }


    public int processBatch(Queue<Object> exchanges) throws Exception {
        int total = exchanges.size();

        // limit if needed
        if (maxMessagesPerPoll > 0 && total > maxMessagesPerPoll) {
            LOG.debug("Limiting to maximum messages to poll " + maxMessagesPerPoll + " as there was " + total + " messages in this poll.");
            total = maxMessagesPerPoll;
        }

        for (int index = 0; index < total && isBatchAllowed(); index++) {
            // only loop if we are started (allowed to run)
            DataHolder holder = ObjectHelper.cast(DataHolder.class, exchanges.poll());
            EntityManager entityManager = holder.manager;
            Exchange exchange = holder.exchange;
            Object result = holder.result;

            // add current index and total as properties
            exchange.setProperty(Exchange.BATCH_INDEX, index);
            exchange.setProperty(Exchange.BATCH_SIZE, total);
            exchange.setProperty(Exchange.BATCH_COMPLETE, index == total - 1);

            // update pending number of exchanges
            pendingExchanges = total - index - 1;
            if (lockEntity(result, entityManager)) {
                // Run the @PreConsumed callback
                createPreDeleteHandler().deleteObject(entityManager, result, exchange);

                // process the current exchange
                LOG.debug("Processing exchange: {}", exchange);
                getProcessor().process(exchange);
                if (exchange.getException() != null) {
                    // if we failed then throw exception
                    throw exchange.getException();
                }

                // Run the @Consumed callback
                getDeleteHandler().deleteObject(entityManager, result, exchange);
            }
        }

        return total;
    }

    // Properties
    // -------------------------------------------------------------------------
    @Override
    public JpaEndpoint getEndpoint() {
        return (JpaEndpoint) super.getEndpoint();
    }

    public QueryFactory getQueryFactory() {
        if (queryFactory == null) {
            queryFactory = createQueryFactory();
            if (queryFactory == null) {
                throw new IllegalArgumentException("No queryType property configured on this consumer, nor an entityType configured on the endpoint so cannot consume");
            }
        }
        return queryFactory;
    }

    public void setQueryFactory(QueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    public DeleteHandler<Object> getDeleteHandler() {
        if (deleteHandler == null) {
            deleteHandler = createDeleteHandler();
        }
        return deleteHandler;
    }

    public void setDeleteHandler(DeleteHandler<Object> deleteHandler) {
        this.deleteHandler = deleteHandler;
    }

    public DeleteHandler<Object> getPreDeleteHandler() {
        if (preDeleteHandler == null) {
            preDeleteHandler = createPreDeleteHandler();
        }
        return preDeleteHandler;
    }

    public void setPreDeleteHandler(DeleteHandler<Object> preDeleteHandler) {
        this.preDeleteHandler = preDeleteHandler;
    }

    public void setParameters(Map<String, Object> params) {
        this.parameters = params;
    }
    
    public Map<String, Object> getParameters() {
        return parameters;
    }

    public String getNamedQuery() {
        return namedQuery;
    }

    public void setNamedQuery(String namedQuery) {
        this.namedQuery = namedQuery;
    }

    public LockModeType getLockModeType() {
        return lockModeType;
    }

    public void setLockModeType(LockModeType lockModeType) {
        this.lockModeType = lockModeType;
    }

    public String getNativeQuery() {
        return nativeQuery;
    }

    public void setNativeQuery(String nativeQuery) {
        this.nativeQuery = nativeQuery;
    }

    public String getQuery() {
        return query;
    }

    public void setQuery(String query) {
        this.query = query;
    }
    
    public Class<?> getResultClass() {
        return resultClass;
    }

    public void setResultClass(Class<?> resultClass) {
        this.resultClass = resultClass;
    }

    public boolean isTransacted() {
        return transacted;
    }

    /**
     * Sets whether to run in transacted mode or not.
     * <p/>
     * This option is default <tt>false</tt>. When <tt>false</tt> then all the good messages
     * will commit, and the first failed message will rollback.
     * However when <tt>true</tt>, then all messages will rollback, if just one message failed.
     */
    public void setTransacted(boolean transacted) {
        this.transacted = transacted;
    }

    /**
     * Sets whether to use NOWAIT on lock and silently skip the entity. This
     * allows different instances to process entities at the same time but not
     * processing the same entity.
     * 
     * @param skipLockedEntity
     */
    public void setSkipLockedEntity(boolean skipLockedEntity) {
        this.skipLockedEntity = skipLockedEntity;
    }

    public boolean isSkipLockedEntity() {
        return skipLockedEntity;
    }

    // Implementation methods
    // -------------------------------------------------------------------------

    /**
     * A strategy method to lock an object with an exclusive lock so that it can
     * be processed
     * 
     * @param entity the entity to be locked
     * @param entityManager entity manager
     * @return true if the entity was locked
     */
    protected boolean lockEntity(Object entity, EntityManager entityManager) {
        if (!getEndpoint().isConsumeLockEntity()) {
            return true;
        }
        try {
            LOG.debug("Acquiring exclusive lock on entity: {}", entity);
            if (isSkipLockedEntity()) {
                entityManager.lock(entity, lockModeType, NOWAIT);
            } else {
                entityManager.lock(entity, lockModeType);
            }
            return true;
        } catch (Exception e) {
            if (LOG.isDebugEnabled()) {
                LOG.debug("Failed to achieve lock on entity: " + entity + ". Reason: " + e, e);
            }
            if (e instanceof PessimisticLockException || e instanceof OptimisticLockException) {
                //transaction marked as rollback can't continue gracefully
                throw (PersistenceException) e;
            }
            //TODO: Find if possible an alternative way to handle results of native queries.
            //Result of native queries are Arrays and cannot be locked by all JPA Providers.
            if (entity.getClass().isArray()) {
                return true;
            }
            return false;
        }
    }

    protected QueryFactory createQueryFactory() {
        if (query != null) {
            return QueryBuilder.query(query);
        } else if (namedQuery != null) {
            return QueryBuilder.namedQuery(namedQuery);
        } else if (nativeQuery != null) {
            if (resultClass != null) {
                return QueryBuilder.nativeQuery(nativeQuery, resultClass);
            } else {
                return QueryBuilder.nativeQuery(nativeQuery);                
            }
        } else {
            Class<?> entityType = getEndpoint().getEntityType();
            
            if (entityType == null) {
                return null;
            } else {
                // Check if we have a property name on the @Entity annotation
                String name = getEntityName(entityType);
                if (name != null) {
                    return QueryBuilder.query("select x from " + name + " x");
                } else {
                    // Remove package name of the entity to be conform with JPA 1.0 spec
                    return QueryBuilder.query("select x from " + entityType.getSimpleName() + " x");
                }
            }
        }
    }
    
    protected String getEntityName(Class<?> clazz) {
        Entity entity = clazz.getAnnotation(Entity.class);

        // Check if the property name has been defined for Entity annotation
        if (entity != null && !entity.name().equals("")) {
            return entity.name();
        } else {
            return null;
        }
    }

    protected DeleteHandler<Object> createPreDeleteHandler() {
        // Look for @PreConsumed to allow custom callback before the Entity has been consumed
        final Class<?> entityType = getEndpoint().getEntityType();
        if (entityType != null) {
            // Inspect the method(s) annotated with @PreConsumed
            List<Method> methods = ObjectHelper.findMethodsWithAnnotation(entityType, PreConsumed.class);
            if (methods.size() > 1) {
                throw new IllegalStateException("Only one method can be annotated with the @PreConsumed annotation but found: " + methods);
            } else if (methods.size() == 1) {
                // Inspect the parameters of the @PreConsumed method
                final Method method = methods.get(0);
                final boolean useExchangeParameter = checkParameters(method);
                return new DeleteHandler<Object>() {
                    @Override
                    public void deleteObject(EntityManager entityManager, Object entityBean, Exchange exchange) {
                        // The entityBean could be an Object array
                        if (entityType.isInstance(entityBean)) {
                            if (useExchangeParameter) {
                                ObjectHelper.invokeMethod(method, entityBean, exchange);
                            } else {
                                ObjectHelper.invokeMethod(method, entityBean);
                            }
                        }
                    }
                };
            }
        }

        // else do nothing
        return new DeleteHandler<Object>() {
            @Override
            public void deleteObject(EntityManager entityManager, Object entityBean, Exchange exchange) {
                // Do nothing
            }
        };
    }

    protected DeleteHandler<Object> createDeleteHandler() {
        // look for @Consumed to allow custom callback when the Entity has been consumed
        final Class<?> entityType = getEndpoint().getEntityType();
        if (entityType != null) {
            List<Method> methods = ObjectHelper.findMethodsWithAnnotation(entityType, Consumed.class);
            if (methods.size() > 1) {
                throw new IllegalArgumentException("Only one method can be annotated with the @Consumed annotation but found: " + methods);
            } else if (methods.size() == 1) {
                final Method method = methods.get(0);
                final boolean useExchangeParameter = checkParameters(method);
                return new DeleteHandler<Object>() {
                    public void deleteObject(EntityManager entityManager, Object entityBean, Exchange exchange) {
                        if (entityType.isInstance(entityBean)) {
                            if (useExchangeParameter) {
                                ObjectHelper.invokeMethod(method, entityBean, exchange);
                            } else {
                                ObjectHelper.invokeMethod(method, entityBean);
                            }
                        }
                    }
                };
            }
        }
        if (getEndpoint().isConsumeDelete()) {
            return new DeleteHandler<Object>() {
                public void deleteObject(EntityManager entityManager, Object entityBean, Exchange exchange) {
                    entityManager.remove(entityBean);
                }
            };
        } else {
            return new DeleteHandler<Object>() {
                public void deleteObject(EntityManager entityManager, Object entityBean, Exchange exchange) {
                    // do nothing
                }
            };
        }
    }
    
    protected boolean checkParameters(Method method) {
        boolean result = false;
        Class<?>[] parameters = method.getParameterTypes();
        if (parameters.length == 1 && parameters[0].isAssignableFrom(Exchange.class)) {
            result = true;
        } 
        if (parameters.length > 0 && !result) {
            throw new IllegalStateException("@PreConsumed annotated method cannot have parameter other than Exchange");
        }
        return result;
    }

    protected void configureParameters(Query query) {
        int maxResults = getEndpoint().getMaximumResults();
        if (maxResults > 0) {
            query.setMaxResults(maxResults);
        }
        // setup the parameter
        if (parameters != null) {
            for (Entry<String, Object> entry : parameters.entrySet()) {
                query.setParameter(entry.getKey(), entry.getValue());
            }
        }
    }

    protected Exchange createExchange(Object result) {
        Exchange exchange = getEndpoint().createExchange();
        exchange.getIn().setBody(result);
        exchange.getIn().setHeader(JpaConstants.ENTITYMANAGER, entityManager);
        return exchange;
    }

    @Override
    protected void doStart() throws Exception {
        super.doStart();
        this.entityManager = entityManagerFactory.createEntityManager();
        LOG.trace("Created EntityManager {} on {}", entityManager, this);
    }

    @Override
    protected void doStop() throws Exception {
        // noop
    }

    @Override
    protected void doShutdown() throws Exception {
        super.doShutdown();
        this.entityManager.close();
        LOG.trace("Closed EntityManager {} on {}", entityManager, this);
    }
}
