/*
 * 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.activemq.transport.amqp.protocol;

import static org.apache.activemq.transport.amqp.AmqpSupport.toLong;

import java.io.IOException;
import java.util.LinkedList;
import java.util.concurrent.atomic.AtomicInteger;

import org.apache.activemq.broker.region.AbstractSubscription;
import org.apache.activemq.command.ActiveMQDestination;
import org.apache.activemq.command.ActiveMQMessage;
import org.apache.activemq.command.ConsumerControl;
import org.apache.activemq.command.ConsumerId;
import org.apache.activemq.command.ConsumerInfo;
import org.apache.activemq.command.ExceptionResponse;
import org.apache.activemq.command.LocalTransactionId;
import org.apache.activemq.command.MessageAck;
import org.apache.activemq.command.MessageDispatch;
import org.apache.activemq.command.MessagePull;
import org.apache.activemq.command.RemoveInfo;
import org.apache.activemq.command.RemoveSubscriptionInfo;
import org.apache.activemq.command.Response;
import org.apache.activemq.command.TransactionId;
import org.apache.activemq.transport.amqp.AmqpProtocolConverter;
import org.apache.activemq.transport.amqp.message.AutoOutboundTransformer;
import org.apache.activemq.transport.amqp.message.EncodedMessage;
import org.apache.activemq.transport.amqp.message.OutboundTransformer;
import org.apache.qpid.proton.amqp.messaging.Accepted;
import org.apache.qpid.proton.amqp.messaging.Modified;
import org.apache.qpid.proton.amqp.messaging.Outcome;
import org.apache.qpid.proton.amqp.messaging.Rejected;
import org.apache.qpid.proton.amqp.messaging.Released;
import org.apache.qpid.proton.amqp.transaction.TransactionalState;
import org.apache.qpid.proton.amqp.transport.AmqpError;
import org.apache.qpid.proton.amqp.transport.DeliveryState;
import org.apache.qpid.proton.amqp.transport.ErrorCondition;
import org.apache.qpid.proton.amqp.transport.ReceiverSettleMode;
import org.apache.qpid.proton.amqp.transport.SenderSettleMode;
import org.apache.qpid.proton.engine.Delivery;
import org.apache.qpid.proton.engine.Link;
import org.apache.qpid.proton.engine.Sender;
import org.fusesource.hawtbuf.Buffer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * An AmqpSender wraps the AMQP Sender end of a link from the remote peer
 * which holds the corresponding Receiver which receives messages transfered
 * across the link from the Broker.
 *
 * An AmqpSender is in turn a message consumer subscribed to some destination
 * on the broker.  As messages are dispatched to this sender that are sent on
 * to the remote Receiver end of the lin.
 */
public class AmqpSender extends AmqpAbstractLink<Sender> {

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

    private static final byte[] EMPTY_BYTE_ARRAY = new byte[] {};

    private final OutboundTransformer outboundTransformer = new AutoOutboundTransformer();
    private final AmqpTransferTagGenerator tagCache = new AmqpTransferTagGenerator();
    private final LinkedList<MessageDispatch> outbound = new LinkedList<>();
    private final LinkedList<Delivery> dispatchedInTx = new LinkedList<>();

    private final ConsumerInfo consumerInfo;
    private AbstractSubscription subscription;
    private AtomicInteger prefetchExtension;
    private int currentCreditRequest;
    private int logicalDeliveryCount; // echoes prefetch extension but from protons perspective
    private final boolean presettle;

    private boolean draining;
    private long lastDeliveredSequenceId;

    private Buffer currentBuffer;
    private Delivery currentDelivery;

    /**
     * Creates a new AmqpSender instance that manages the given Sender
     *
     * @param session
     *        the AmqpSession object that is the parent of this instance.
     * @param endpoint
     *        the AMQP Sender instance that this class manages.
     * @param consumerInfo
     *        the ConsumerInfo instance that holds configuration for this sender.
     */
    public AmqpSender(AmqpSession session, Sender endpoint, ConsumerInfo consumerInfo) {
        super(session, endpoint);

        // We don't support second so enforce it as First and let remote decide what to do
        this.endpoint.setReceiverSettleMode(ReceiverSettleMode.FIRST);

        // Match what the sender mode is
        this.endpoint.setSenderSettleMode(endpoint.getRemoteSenderSettleMode());

        this.consumerInfo = consumerInfo;
        this.presettle = getEndpoint().getSenderSettleMode() == SenderSettleMode.SETTLED;
    }

    @Override
    public void open() {
        if (!isClosed()) {
            session.registerSender(getConsumerId(), this);
            subscription = (AbstractSubscription)session.getConnection().lookupPrefetchSubscription(consumerInfo);
            prefetchExtension = subscription.getPrefetchExtension();
        }

        super.open();
    }

    @Override
    public void detach() {
        if (!isClosed() && isOpened()) {
            RemoveInfo removeCommand = new RemoveInfo(getConsumerId());
            removeCommand.setLastDeliveredSequenceId(lastDeliveredSequenceId);

            sendToActiveMQ(removeCommand, new ResponseHandler() {

                @Override
                public void onResponse(AmqpProtocolConverter converter, Response response) throws IOException {
                    session.unregisterSender(getConsumerId());
                    AmqpSender.super.detach();
                }
            });
        } else {
            super.detach();
        }
    }

    @Override
    public void close() {
        if (!isClosed() && isOpened()) {
            RemoveInfo removeCommand = new RemoveInfo(getConsumerId());
            removeCommand.setLastDeliveredSequenceId(lastDeliveredSequenceId);

            sendToActiveMQ(removeCommand, new ResponseHandler() {

                @Override
                public void onResponse(AmqpProtocolConverter converter, Response response) throws IOException {
                    if (consumerInfo.isDurable()) {
                        RemoveSubscriptionInfo rsi = new RemoveSubscriptionInfo();
                        rsi.setConnectionId(session.getConnection().getConnectionId());
                        rsi.setSubscriptionName(getEndpoint().getName());
                        rsi.setClientId(session.getConnection().getClientId());

                        sendToActiveMQ(rsi);
                    }

                    session.unregisterSender(getConsumerId());
                    AmqpSender.super.close();
                }
            });
        } else {
            super.close();
        }
    }

    @Override
    public void flow() throws Exception {
        Link endpoint = getEndpoint();
        if (LOG.isTraceEnabled()) {
            LOG.trace("Flow: draining={}, drain={} credit={}, currentCredit={}, senderDeliveryCount={} - Sub={}",
                    draining, endpoint.getDrain(),
                    endpoint.getCredit(), currentCreditRequest, logicalDeliveryCount, subscription);
        }

        final int endpointCredit = endpoint.getCredit();
        if (endpoint.getDrain() && !draining) {

            if (endpointCredit > 0) {
                draining = true;

                // Now request dispatch of the drain amount, we request immediate
                // timeout and an completion message regardless so that we can know
                // when we should marked the link as drained.
                MessagePull pullRequest = new MessagePull();
                pullRequest.setConsumerId(getConsumerId());
                pullRequest.setDestination(getDestination());
                pullRequest.setTimeout(-1);
                pullRequest.setAlwaysSignalDone(true);
                pullRequest.setQuantity(endpointCredit);

                LOG.trace("Pull case -> consumer pull request quantity = {}", endpointCredit);

                sendToActiveMQ(pullRequest);
            } else {
                LOG.trace("Pull case -> sending any Queued messages and marking drained");

                pumpOutbound();
                getEndpoint().drained();
                session.pumpProtonToSocket();
                currentCreditRequest = 0;
                logicalDeliveryCount = 0;
            }
        } else if (endpointCredit >= 0) {

            if (endpointCredit == 0 && currentCreditRequest != 0) {
                prefetchExtension.set(0);
                currentCreditRequest = 0;
                logicalDeliveryCount = 0;
                LOG.trace("Flow: credit 0 for sub:" + subscription);
            } else {
                int deltaToAdd = endpointCredit;
                int logicalCredit = currentCreditRequest - logicalDeliveryCount;
                if (logicalCredit > 0) {
                    deltaToAdd -= logicalCredit;
                } else {
                    // reset delivery counter - dispatch from broker concurrent with credit=0
                    // flow can go negative
                    logicalDeliveryCount = 0;
                }

                if (deltaToAdd > 0) {
                    currentCreditRequest = prefetchExtension.addAndGet(deltaToAdd);
                    subscription.wakeupDestinationsForDispatch();
                    // force dispatch of matched/pending for topics (pending messages accumulate
                    // in the sub and are dispatched on update of prefetch)
                    subscription.setPrefetchSize(0);
                    LOG.trace("Flow: credit addition of {} for sub {}", deltaToAdd, subscription);
                }
            }
        }
    }

    @Override
    public void delivery(Delivery delivery) throws Exception {
        MessageDispatch md = (MessageDispatch) delivery.getContext();
        DeliveryState state = delivery.getRemoteState();

        if (state instanceof TransactionalState) {
            TransactionalState txState = (TransactionalState) state;
            LOG.trace("onDelivery: TX delivery state = {}", state);
            if (txState.getOutcome() != null) {
                Outcome outcome = txState.getOutcome();
                if (outcome instanceof Accepted) {
                    TransactionId txId = new LocalTransactionId(session.getConnection().getConnectionId(), toLong(txState.getTxnId()));

                    // Store the message sent in this TX we might need to re-send on rollback
                    // and we need to ACK it on commit.
                    session.enlist(txId);
                    dispatchedInTx.addFirst(delivery);

                    if (!delivery.remotelySettled()) {
                        TransactionalState txAccepted = new TransactionalState();
                        txAccepted.setOutcome(Accepted.getInstance());
                        txAccepted.setTxnId(txState.getTxnId());

                        delivery.disposition(txAccepted);
                    }
                }
            }
        } else {
            if (state instanceof Accepted) {
                LOG.trace("onDelivery: accepted state = {}", state);
                if (!delivery.remotelySettled()) {
                    delivery.disposition(new Accepted());
                }
                settle(delivery, MessageAck.INDIVIDUAL_ACK_TYPE);
            } else if (state instanceof Rejected) {
                // Rejection is a terminal outcome, we poison the message for dispatch to
                // the DLQ.  If a custom redelivery policy is used on the broker the message
                // can still be redelivered based on the configation of that policy.
                LOG.trace("onDelivery: Rejected state = {}, message poisoned.", state);
                settle(delivery, MessageAck.POISON_ACK_TYPE);
            } else if (state instanceof Released) {
                LOG.trace("onDelivery: Released state = {}", state);
                // re-deliver && don't increment the counter.
                settle(delivery, -1);
            } else if (state instanceof Modified) {
                Modified modified = (Modified) state;
                if (Boolean.TRUE.equals(modified.getDeliveryFailed())) {
                    // increment delivery counter..
                    md.setRedeliveryCounter(md.getRedeliveryCounter() + 1);
                }
                LOG.trace("onDelivery: Modified state = {}, delivery count now {}", state, md.getRedeliveryCounter());
                byte ackType = -1;
                Boolean undeliverableHere = modified.getUndeliverableHere();
                if (undeliverableHere != null && undeliverableHere) {
                    // receiver does not want the message..
                    // perhaps we should DLQ it?
                    ackType = MessageAck.POISON_ACK_TYPE;
                }
                settle(delivery, ackType);
            }
        }

        pumpOutbound();
    }

    @Override
    public void commit(LocalTransactionId txnId) throws Exception {
        if (!dispatchedInTx.isEmpty()) {
            for (final Delivery delivery : dispatchedInTx) {
                MessageDispatch dispatch = (MessageDispatch) delivery.getContext();

                MessageAck pendingTxAck = new MessageAck(dispatch, MessageAck.INDIVIDUAL_ACK_TYPE, 1);
                pendingTxAck.setFirstMessageId(dispatch.getMessage().getMessageId());
                pendingTxAck.setTransactionId(txnId);

                LOG.trace("Sending commit Ack to ActiveMQ: {}", pendingTxAck);

                sendToActiveMQ(pendingTxAck, new ResponseHandler() {
                    @Override
                    public void onResponse(AmqpProtocolConverter converter, Response response) throws IOException {
                        if (response.isException()) {
                            Throwable exception = ((ExceptionResponse) response).getException();
                            exception.printStackTrace();
                            getEndpoint().close();
                        } else {
                            delivery.settle();
                        }
                        session.pumpProtonToSocket();
                    }
                });
            }

            dispatchedInTx.clear();
        }
    }

    @Override
    public void rollback(LocalTransactionId txnId) throws Exception {
        synchronized (outbound) {

            LOG.trace("Rolling back {} messages for redelivery. ", dispatchedInTx.size());

            for (Delivery delivery : dispatchedInTx) {
                // Only settled deliveries should be re-dispatched, unsettled deliveries
                // remain acquired on the remote end and can be accepted again in a new
                // TX or released or rejected etc.
                MessageDispatch dispatch = (MessageDispatch) delivery.getContext();
                dispatch.getMessage().setTransactionId(null);

                if (delivery.remotelySettled()) {
                    dispatch.setRedeliveryCounter(dispatch.getRedeliveryCounter() + 1);
                    outbound.addFirst(dispatch);
                }
            }

            dispatchedInTx.clear();
        }
    }

    /**
     * Event point for incoming message from ActiveMQ on this Sender's
     * corresponding subscription.
     *
     * @param dispatch
     *        the MessageDispatch to process and send across the link.
     *
     * @throws Exception if an error occurs while encoding the message for send.
     */
    public void onMessageDispatch(MessageDispatch dispatch) throws Exception {
        if (!isClosed()) {
            // Lock to prevent stepping on TX redelivery
            synchronized (outbound) {
                outbound.addLast(dispatch);
            }
            pumpOutbound();
            session.pumpProtonToSocket();
        }
    }

    /**
     * Called when the Broker sends a ConsumerControl command to the Consumer that
     * this sender creates to obtain messages to dispatch via the sender for this
     * end of the open link.
     *
     * @param control
     *        The ConsumerControl command to process.
     */
    public void onConsumerControl(ConsumerControl control) {
        if (control.isClose()) {
            close(new ErrorCondition(AmqpError.INTERNAL_ERROR, "Receiver forcably closed"));
            session.pumpProtonToSocket();
        }
    }

    @Override
    public String toString() {
        return "AmqpSender {" + getConsumerId() + "}";
    }

    //----- Property getters and setters -------------------------------------//

    public ConsumerId getConsumerId() {
        return consumerInfo.getConsumerId();
    }

    @Override
    public ActiveMQDestination getDestination() {
        return consumerInfo.getDestination();
    }

    @Override
    public void setDestination(ActiveMQDestination destination) {
        consumerInfo.setDestination(destination);
    }

    //----- Internal Implementation ------------------------------------------//

    public void pumpOutbound() throws Exception {
        while (!isClosed()) {
            while (currentBuffer != null) {
                int sent = getEndpoint().send(currentBuffer.data, currentBuffer.offset, currentBuffer.length);
                if (sent > 0) {
                    currentBuffer.moveHead(sent);
                    if (currentBuffer.length == 0) {
                        if (presettle) {
                            settle(currentDelivery, MessageAck.INDIVIDUAL_ACK_TYPE);
                        } else {
                            getEndpoint().advance();
                        }
                        currentBuffer = null;
                        currentDelivery = null;
                        logicalDeliveryCount++;
                    }
                } else {
                    return;
                }
            }

            if (outbound.isEmpty()) {
                return;
            }

            final MessageDispatch md = outbound.removeFirst();
            try {

                ActiveMQMessage temp = null;
                if (md.getMessage() != null) {
                    temp = (ActiveMQMessage) md.getMessage().copy();
                }

                final ActiveMQMessage jms = temp;
                if (jms == null) {
                    LOG.trace("Sender:[{}] browse done.", getEndpoint().getName());
                    // It's the end of browse signal in response to a MessagePull
                    getEndpoint().drained();
                    draining = false;
                    currentCreditRequest = 0;
                    logicalDeliveryCount = 0;
                } else {
                    if (LOG.isTraceEnabled()) {
                        LOG.trace("Sender:[{}] msgId={} draining={}, drain={}, credit={}, remoteCredit={}, queued={}",
                                  getEndpoint().getName(), jms.getJMSMessageID(), draining, getEndpoint().getDrain(),
                                  getEndpoint().getCredit(), getEndpoint().getRemoteCredit(), getEndpoint().getQueued());
                    }

                    if (draining && getEndpoint().getCredit() == 0) {
                        LOG.trace("Sender:[{}] browse complete.", getEndpoint().getName());
                        getEndpoint().drained();
                        draining = false;
                        currentCreditRequest = 0;
                        logicalDeliveryCount = 0;
                    }

                    jms.setRedeliveryCounter(md.getRedeliveryCounter());
                    jms.setReadOnlyBody(true);
                    final EncodedMessage amqp = outboundTransformer.transform(jms);
                    if (amqp != null && amqp.getLength() > 0) {
                        currentBuffer = new Buffer(amqp.getArray(), amqp.getArrayOffset(), amqp.getLength());
                        if (presettle) {
                            currentDelivery = getEndpoint().delivery(EMPTY_BYTE_ARRAY, 0, 0);
                        } else {
                            final byte[] tag = tagCache.getNextTag();
                            currentDelivery = getEndpoint().delivery(tag, 0, tag.length);
                        }
                        currentDelivery.setContext(md);
                        currentDelivery.setMessageFormat((int) amqp.getMessageFormat());
                    } else {
                        // TODO: message could not be generated what now?
                    }
                }
            } catch (Exception e) {
                LOG.warn("Error detected while flushing outbound messages: {}", e.getMessage());
            }
        }
    }

    private void settle(final Delivery delivery, final int ackType) throws Exception {
        byte[] tag = delivery.getTag();
        if (tag != null && tag.length > 0 && delivery.remotelySettled()) {
            tagCache.returnTag(tag);
        }

        if (ackType == -1) {
            // we are going to settle, but redeliver.. we we won't yet ack to ActiveMQ
            delivery.settle();
            onMessageDispatch((MessageDispatch) delivery.getContext());
        } else {
            MessageDispatch md = (MessageDispatch) delivery.getContext();
            lastDeliveredSequenceId = md.getMessage().getMessageId().getBrokerSequenceId();
            MessageAck ack = new MessageAck();
            ack.setConsumerId(getConsumerId());
            ack.setFirstMessageId(md.getMessage().getMessageId());
            ack.setLastMessageId(md.getMessage().getMessageId());
            ack.setMessageCount(1);
            ack.setAckType((byte) ackType);
            ack.setDestination(md.getDestination());
            LOG.trace("Sending Ack to ActiveMQ: {}", ack);

            sendToActiveMQ(ack, new ResponseHandler() {
                @Override
                public void onResponse(AmqpProtocolConverter converter, Response response) throws IOException {
                    if (response.isException()) {
                        if (response.isException()) {
                            Throwable exception = ((ExceptionResponse) response).getException();
                            exception.printStackTrace();
                            getEndpoint().close();
                        }
                    } else {
                        delivery.settle();
                    }
                    session.pumpProtonToSocket();
                }
            });
        }
    }
}
