/*
 * 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.qpid.jms.provider.amqp;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

import javax.jms.IllegalStateException;
import javax.jms.TransactionRolledBackException;

import org.apache.qpid.jms.meta.JmsConsumerId;
import org.apache.qpid.jms.meta.JmsProducerId;
import org.apache.qpid.jms.meta.JmsSessionInfo;
import org.apache.qpid.jms.meta.JmsTransactionId;
import org.apache.qpid.jms.meta.JmsTransactionInfo;
import org.apache.qpid.jms.provider.AsyncResult;
import org.apache.qpid.jms.provider.amqp.builders.AmqpTransactionCoordinatorBuilder;
import org.apache.qpid.proton.amqp.Binary;
import org.apache.qpid.proton.amqp.messaging.Accepted;
import org.apache.qpid.proton.amqp.transaction.TransactionalState;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Handles the operations surrounding AMQP transaction control.
 *
 * The Transaction will carry a JmsTransactionId while the Transaction is open, once a
 * transaction has been committed or rolled back the Transaction Id is cleared.
 */
public class AmqpTransactionContext implements AmqpResourceParent {

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

    private final AmqpSession session;
    private final Map<JmsConsumerId, AmqpConsumer> txConsumers = new HashMap<>();
    private final Map<JmsProducerId, AmqpProducer> txProducers = new HashMap<>();

    private JmsTransactionId current;
    private TransactionalState cachedAcceptedState;
    private TransactionalState cachedTransactedState;
    private AmqpTransactionCoordinator coordinator;

    /**
     * Creates a new AmqpTransactionContext instance.
     *
     * @param session
     *        The session that owns this transaction context.
     * @param resourceInfo
     *        The resourceInfo that defines this transaction context.
     */
    public AmqpTransactionContext(AmqpSession session, JmsSessionInfo resourceInfo) {
        this.session = session;
    }

    public void begin(final JmsTransactionId txId, final AsyncResult request) throws Exception {
        if (current != null) {
            throw new IOException("Begin called while a TX is still Active.");
        }

        final AsyncResult declareCompletion = new AsyncResult() {

            @Override
            public void onSuccess() {
                current = txId;
                cachedAcceptedState = new TransactionalState();
                cachedAcceptedState.setOutcome(Accepted.getInstance());
                cachedAcceptedState.setTxnId(getAmqpTransactionId());
                cachedTransactedState = new TransactionalState();
                cachedTransactedState.setTxnId(getAmqpTransactionId());
                request.onSuccess();
            }

            @Override
            public void onFailure(Throwable result) {
                current = null;
                cachedAcceptedState = null;
                cachedTransactedState = null;
                request.onFailure(result);
            }

            @Override
            public boolean isComplete() {
                return current != null;
            }
        };

        if (coordinator == null || coordinator.isClosed()) {
            AmqpTransactionCoordinatorBuilder builder =
                new AmqpTransactionCoordinatorBuilder(this, session.getResourceInfo());
            builder.buildResource(new AsyncResult() {

                @Override
                public void onSuccess() {
                    try {
                        coordinator.declare(txId, declareCompletion);
                    } catch (Exception e) {
                        request.onFailure(e);
                    }
                }

                @Override
                public void onFailure(Throwable result) {
                    request.onFailure(result);
                }

                @Override
                public boolean isComplete() {
                    return request.isComplete();
                }
            });
        } else {
            coordinator.declare(txId, declareCompletion);
        }
    }

    public void commit(final JmsTransactionInfo transactionInfo, JmsTransactionInfo nextTransactionInfo, final AsyncResult request) throws Exception {
        if (!transactionInfo.getId().equals(current)) {
            if (!transactionInfo.isInDoubt() && current == null) {
                throw new IllegalStateException("Commit called with no active Transaction.");
            } else if (!transactionInfo.isInDoubt() && current != null) {
                throw new IllegalStateException("Attempt to Commit a transaction other than the current one");
            } else {
                throw new TransactionRolledBackException("Transaction in doubt and cannot be committed.");
            }
        }

        preCommit();

        LOG.trace("TX Context[{}] committing current TX[[]]", this, current);

        DischargeCompletion completion = new DischargeCompletion(request, nextTransactionInfo, true);

        coordinator.discharge(current, completion);
        current = null;

        if (completion.isPipelined()) {
            // If the discharge completed abnormally then we don't bother creating a new TX as the
            // caller will determine how to recover.
            if (!completion.isComplete()) {
                begin(nextTransactionInfo.getId(), completion.getDeclareCompletion());
            } else {
                completion.getDeclareCompletion().onFailure(completion.getFailureCause());
            }
        }
    }

    public void rollback(JmsTransactionInfo transactionInfo, JmsTransactionInfo nextTransactionInfo, final AsyncResult request) throws Exception {
        if (!transactionInfo.getId().equals(current)) {
            if (!transactionInfo.isInDoubt() && current == null) {
                throw new IllegalStateException("Rollback called with no active Transaction.");
            } else if (!transactionInfo.isInDoubt() && current != null) {
                throw new IllegalStateException("Attempt to rollback a transaction other than the current one");
            } else {
                request.onSuccess();
                return;
            }
        }

        preRollback();

        LOG.trace("TX Context[{}] rolling back current TX[[]]", this, current);

        DischargeCompletion completion = new DischargeCompletion(request, nextTransactionInfo, false);

        coordinator.discharge(current, completion);
        current = null;

        if (completion.isPipelined()) {
            // If the discharge completed abnormally then we don't bother creating a new TX as the
            // caller will determine how to recover.
            if (!completion.isComplete()) {
                begin(nextTransactionInfo.getId(), completion.getDeclareCompletion());
            } else {
                completion.getDeclareCompletion().onFailure(completion.getFailureCause());
            }
        }
    }

    //----- Context utility methods ------------------------------------------//

    public void registerTxConsumer(AmqpConsumer consumer) {
        txConsumers.put(consumer.getConsumerId(), consumer);
    }

    public boolean isInTransaction(JmsConsumerId consumerId) {
        return txConsumers.containsKey(consumerId);
    }

    public void registerTxProducer(AmqpProducer producer) {
        txProducers.put(producer.getProducerId(), producer);
    }

    public boolean isInTransaction(JmsProducerId producerId) {
        return txProducers.containsKey(producerId);
    }

    public AmqpSession getSession() {
        return session;
    }

    public TransactionalState getTxnAcceptState() {
        return cachedAcceptedState;
    }

    public TransactionalState getTxnEnrolledState() {
        return cachedTransactedState;
    }

    public JmsTransactionId getTransactionId() {
        return current;
    }

    public boolean isTransactionFailed() {
        return coordinator == null ? false : coordinator.isClosed();
    }

    public Binary getAmqpTransactionId() {
        Binary result = null;
        if (current != null) {
            result = (Binary) current.getProviderHint();
        }
        return result;
    }

    @Override
    public String toString() {
        return this.session.getSessionId() + ": txContext";
    }

    //----- Transaction pre / post completion --------------------------------//

    private void preCommit() {
        for (AmqpConsumer consumer : txConsumers.values()) {
            consumer.preCommit();
        }
    }

    private void preRollback() {
        for (AmqpConsumer consumer : txConsumers.values()) {
            consumer.preRollback();
        }
    }

    private void postCommit() {
        for (AmqpConsumer consumer : txConsumers.values()) {
            consumer.postCommit();
        }

        txConsumers.clear();
        txProducers.clear();
    }

    private void postRollback() {
        for (AmqpConsumer consumer : txConsumers.values()) {
            consumer.postRollback();
        }

        txConsumers.clear();
        txProducers.clear();
    }

    //----- Resource Parent implementation -----------------------------------//

    @Override
    public void addChildResource(AmqpResource resource) {
        if (resource instanceof AmqpTransactionCoordinator) {
            coordinator = (AmqpTransactionCoordinator) resource;
        }
    }

    @Override
    public void removeChildResource(AmqpResource resource) {
        // We don't clear the coordinator link so that we can refer to it
        // to check if the current TX has failed due to link closed during
        // normal operations.
    }

    @Override
    public AmqpProvider getProvider() {
        return session.getProvider();
    }

    //----- Completion for Commit or Rollback operation ----------------------//

    private abstract class Completion implements AsyncResult {

        protected boolean complete;
        protected Throwable failure;

        @Override
        public boolean isComplete() {
            return complete;
        }

        public Throwable getFailureCause() {
            return failure;
        }
    }

    private class DeclareCompletion extends Completion {

        protected final DischargeCompletion parent;

        public DeclareCompletion(DischargeCompletion parent) {
            this.parent = parent;
        }

        @Override
        public void onFailure(Throwable result) {
            complete = true;
            failure = result;
            parent.onDeclareFailure(result);
        }

        @Override
        public void onSuccess() {
            complete = true;
            parent.onDeclareSuccess();
        }
    }

    public class DischargeCompletion extends Completion {

        private final DeclareCompletion declare;

        private final AsyncResult request;
        private final boolean commit;

        public DischargeCompletion(AsyncResult request, JmsTransactionInfo nextTx, boolean commit) {
            this.request = request;
            this.commit = commit;

            if (nextTx != null) {
                this.declare = new DeclareCompletion(this);
            } else {
                this.declare = null;
            }
        }

        public DeclareCompletion getDeclareCompletion() {
            return declare;
        }

        public boolean isCommit() {
            return commit;
        }

        public boolean isPipelined() {
            return declare != null;
        }

        @Override
        public void onFailure(Throwable result) {
            complete = true;
            failure = result;
            onDischargeFailure(result);
        }

        @Override
        public void onSuccess() {
            complete = true;
            onDischargeSuccess();
        }

        public void onDeclareSuccess() {
            // If the discharge has not completed yet we wait until it does
            // so we end up with the correct result.
            if (isComplete()) {
                if (getFailureCause() == null) {
                    request.onSuccess();
                } else {
                    request.onFailure(getFailureCause());
                }
            }
        }

        public void onDischargeSuccess() {
            cleanup();

            // If the declare already returned a result we can proceed otherwise
            // we need to wait for it finish in order to get the correct outcome.
            if (declare == null) {
                request.onSuccess();
            } else if (declare.isComplete()) {
                if (declare.getFailureCause() == null) {
                    request.onSuccess();
                } else {
                    request.onFailure(declare.getFailureCause());
                }
            }
        }

        public void onDeclareFailure(Throwable failure) {
            // If the discharge has not completed yet we wait until it does
            // so we end up with the correct result.
            if (isComplete()) {
                if (getFailureCause() == null) {
                    request.onFailure(failure);
                } else {
                    request.onFailure(getFailureCause());
                }
            }
        }

        public void onDischargeFailure(Throwable failure) {
            cleanup();

            // If the declare already returned a result we can proceed otherwise
            // we need to wait for it finish in order to get the correct outcome.
            if (declare == null) {
                request.onFailure(failure);
            } else if (declare.isComplete()) {
                request.onFailure(failure);
            }
        }

        private void cleanup() {
            if (commit) {
                postCommit();
            } else {
                postRollback();
            }
        }
    }
}
