/*
 * 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.proton.engine.impl;

import static java.util.EnumSet.of;
import static org.apache.qpid.proton.engine.EndpointState.ACTIVE;
import static org.apache.qpid.proton.engine.EndpointState.UNINITIALIZED;
import static org.apache.qpid.proton.systemtests.TestLoggingHelper.bold;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;

import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.logging.Logger;

import org.apache.qpid.proton.Proton;
import org.apache.qpid.proton.amqp.messaging.Accepted;
import org.apache.qpid.proton.amqp.messaging.AmqpValue;
import org.apache.qpid.proton.amqp.messaging.Source;
import org.apache.qpid.proton.amqp.messaging.Target;
import org.apache.qpid.proton.amqp.transport.ReceiverSettleMode;
import org.apache.qpid.proton.amqp.transport.SenderSettleMode;
import org.apache.qpid.proton.engine.Connection;
import org.apache.qpid.proton.engine.Delivery;
import org.apache.qpid.proton.engine.Receiver;
import org.apache.qpid.proton.engine.Sender;
import org.apache.qpid.proton.engine.Session;
import org.apache.qpid.proton.engine.Transport;
import org.apache.qpid.proton.message.Message;
import org.apache.qpid.proton.systemtests.EngineTestBase;
import org.apache.qpid.proton.systemtests.ProtocolTracerEnabler;
import org.apache.qpid.proton.systemtests.TestLoggingHelper;
import org.junit.Test;

public class DeferredSettlementTest extends EngineTestBase
{
    private static final Logger LOGGER = Logger.getLogger(DeferredSettlementTest.class.getName());

    private static final int BUFFER_SIZE = 4096;

    private final String _sourceAddress = getServer().getContainerId() + "-link1-source";

    @Test
    public void testDeferredOutOfOrderSettlement() throws Exception
    {
        LOGGER.fine(bold("======== About to create transports"));

        Transport clientTransport = Proton.transport();
        getClient().setTransport(clientTransport);
        ProtocolTracerEnabler.setProtocolTracer(clientTransport, TestLoggingHelper.CLIENT_PREFIX);

        Transport serverTransport = Proton.transport();
        getServer().setTransport(serverTransport);
        ProtocolTracerEnabler.setProtocolTracer(serverTransport, "            " + TestLoggingHelper.SERVER_PREFIX);

        doOutputInputCycle();

        Connection clientConnection = Proton.connection();
        getClient().setConnection(clientConnection);
        clientTransport.bind(clientConnection);

        Connection serverConnection = Proton.connection();
        getServer().setConnection(serverConnection);
        serverTransport.bind(serverConnection);

        LOGGER.fine(bold("======== About to open connections"));
        clientConnection.open();
        serverConnection.open();

        doOutputInputCycle();

        LOGGER.fine(bold("======== About to open sessions"));
        Session clientSession = clientConnection.session();
        getClient().setSession(clientSession);
        clientSession.open();

        pumpClientToServer();

        Session serverSession = serverConnection.sessionHead(of(UNINITIALIZED), of(ACTIVE));
        getServer().setSession(serverSession);
        assertEndpointState(serverSession, UNINITIALIZED, ACTIVE);

        serverSession.open();
        assertEndpointState(serverSession, ACTIVE, ACTIVE);

        pumpServerToClient();
        assertEndpointState(clientSession, ACTIVE, ACTIVE);

        LOGGER.fine(bold("======== About to create receiver"));

        Source clientSource = new Source();
        getClient().setSource(clientSource);
        clientSource.setAddress(_sourceAddress);

        Target clientTarget = new Target();
        getClient().setTarget(clientTarget);
        clientTarget.setAddress(null);

        Receiver clientReceiver = clientSession.receiver("link1");
        getClient().setReceiver(clientReceiver);
        clientReceiver.setTarget(clientTarget);
        clientReceiver.setSource(clientSource);

        clientReceiver.setReceiverSettleMode(ReceiverSettleMode.FIRST);
        clientReceiver.setSenderSettleMode(SenderSettleMode.UNSETTLED);

        assertEndpointState(clientReceiver, UNINITIALIZED, UNINITIALIZED);

        clientReceiver.open();
        assertEndpointState(clientReceiver, ACTIVE, UNINITIALIZED);

        pumpClientToServer();

        LOGGER.fine(bold("======== About to set up implicitly created sender"));

        Sender serverSender = (Sender) getServer().getConnection().linkHead(of(UNINITIALIZED), of(ACTIVE));
        getServer().setSender(serverSender);

        serverSender.setReceiverSettleMode(serverSender.getRemoteReceiverSettleMode());
        serverSender.setSenderSettleMode(serverSender.getRemoteSenderSettleMode());

        org.apache.qpid.proton.amqp.transport.Source serverRemoteSource = serverSender.getRemoteSource();
        serverSender.setSource(serverRemoteSource);

        assertEndpointState(serverSender, UNINITIALIZED, ACTIVE);
        serverSender.open();

        assertEndpointState(serverSender, ACTIVE, ACTIVE);

        pumpServerToClient();

        assertEndpointState(clientReceiver, ACTIVE, ACTIVE);

        int messageCount = 5;
        clientReceiver.flow(messageCount);

        pumpClientToServer();

        LOGGER.fine(bold("======== About to create messages and send to the client"));

        DeliveryImpl[] serverDeliveries = sendMessagesToClient(messageCount);

        pumpServerToClient();

        for (int i = 0; i < messageCount; i++) {
            Delivery d = serverDeliveries[i];
            assertNotNull("Should have had a delivery", d);
            assertNull("Delivery shouldnt have local state", d.getLocalState());
            assertNull("Delivery shouldnt have remote state", d.getRemoteState());
        }

        LOGGER.fine(bold("======== About to process the messages on the client"));

        // Grab the original linkHead, assert deliveries are there, keep refs for later
        DeliveryImpl d0 = (DeliveryImpl) clientReceiver.head();
        assertNotNull("Should have a link head", d0);
        DeliveryImpl[] origClientLinkDeliveries = new DeliveryImpl[messageCount];
        for (int i = 0 ; i < messageCount; i++) {
            origClientLinkDeliveries[i] = d0;

            DeliveryImpl linkPrevious = d0.getLinkPrevious();
            DeliveryImpl linkNext = d0.getLinkNext();

            if(i == 0) {
                assertNull("should not have link prev", linkPrevious);
            } else {
                assertNotNull("should have link prev", linkPrevious);
                assertSame("Unexpected delivery at link prev", origClientLinkDeliveries[i - 1], linkPrevious);
                assertSame("Expected to be prior deliveries link next", d0, origClientLinkDeliveries[i - 1].getLinkNext());
            }

            if(i != messageCount - 1) {
                assertNotNull("should have link next", linkNext);
            } else {
                assertNull("should not have link next", linkNext);
            }

            d0 = linkNext;
        }

        // Receive the deliveries and verify contents, marking with matching integer context for easy identification.
        DeliveryImpl[] clientDeliveries = receiveMessagesFromServer(messageCount);

        // Accept but don't settle them all
        for (int i = 0; i < messageCount; i++) {
            Delivery d = clientDeliveries[i];
            assertNotNull("Should have had a delivery", d);

            d.disposition(Accepted.getInstance());
        }

        // Verify the client lists, i.e. deliveries now point to each other where expected
        for (int i = 0 ; i < messageCount; i++) {
            DeliveryImpl d = origClientLinkDeliveries[i];

            assertSame("Unexpected delivery", origClientLinkDeliveries[i], clientDeliveries[i]);

            // Verify the Transport and Link list entries
            if(i == 0) {
                assertDeliveryLinkReferences(d, i, null, origClientLinkDeliveries[1]);
                assertDeliveryTransportWorkReferences(d, i, null, origClientLinkDeliveries[1]);
            } else if (i != messageCount - 1) {
                assertDeliveryLinkReferences(d, i, origClientLinkDeliveries[i - 1], origClientLinkDeliveries[i+1]);
                assertDeliveryTransportWorkReferences(d, i, origClientLinkDeliveries[i - 1], origClientLinkDeliveries[i+1]);
            }
            else {
                assertDeliveryLinkReferences(d, i, origClientLinkDeliveries[i - 1], null);
                assertDeliveryTransportWorkReferences(d, i, origClientLinkDeliveries[i - 1], null);
            }

            // Assert there are no 'work' list entries, as those are for remote peer updates.
            assertDeliveryWorkReferences(d, i, null, null);
        }

        // Verify the server gets intended state changes
        pumpClientToServer();
        for (int i = 0; i < messageCount; i++) {
            DeliveryImpl d = serverDeliveries[i];
            assertNotNull("Should have had a delivery", d);
            assertNull("Delivery shouldnt have local state", d.getLocalState());
            assertEquals("Delivery should have remote state", Accepted.getInstance(), d.getRemoteState());

            // Verify the Link and Work list entries
            if(i == 0) {
                assertDeliveryLinkReferences(d, null, null, serverDeliveries[1]);
                assertDeliveryWorkReferences(d, null, null, serverDeliveries[1]);
            } else if (i != messageCount - 1) {
                assertDeliveryLinkReferences(d, null, serverDeliveries[i - 1], serverDeliveries[i+1]);
                assertDeliveryWorkReferences(d, null, serverDeliveries[i - 1], serverDeliveries[i+1]);
            }
            else {
                assertDeliveryLinkReferences(d, null, serverDeliveries[i - 1], null);
                assertDeliveryWorkReferences(d, null, serverDeliveries[i - 1], null);
            }

            // Assert there are no 'transport work' list entries, as those are for local updates.
            assertDeliveryTransportWorkReferences(d, null, null, null);
        }

        // Settle one from the middle
        int toSettle = messageCount/2;
        assertTrue("need more deliveries", toSettle > 1);
        assertTrue("need more deliveries", toSettle < messageCount - 1);

        DeliveryImpl dSettle = clientDeliveries[toSettle];
        Integer index = getDeliveryContextIndex(dSettle);

        // Verify the server gets intended state changes when settled
        assertFalse("Delivery should not have been remotely settled yet", serverDeliveries[toSettle].remotelySettled());
        dSettle.settle();

        // Verify the client delivery Link and Work list entries are cleared, tpWork is set
        assertDeliveryLinkReferences(dSettle, index, null, null);
        assertDeliveryWorkReferences(dSettle, index, null, null);
        assertDeliveryTransportWorkReferences(dSettle, index, null, null);
        assertSame("expected settled delivery to be client connection tpWork head", dSettle, ((ConnectionImpl) clientConnection).getTransportWorkHead());

        // Verify the client Link and Work list entries are correct for neighbouring deliveries
        assertDeliveryLinkReferences(clientDeliveries[toSettle - 1], index - 1, clientDeliveries[toSettle - 2], clientDeliveries[toSettle + 1]);
        assertDeliveryTransportWorkReferences(clientDeliveries[toSettle - 1], index - 1, null, null);
        assertDeliveryWorkReferences(clientDeliveries[toSettle - 1], index - 1, null, null);

        assertDeliveryLinkReferences(clientDeliveries[toSettle + 1], index + 1, clientDeliveries[toSettle - 1], clientDeliveries[toSettle + 2]);
        assertDeliveryTransportWorkReferences(clientDeliveries[toSettle + 1], index + 1, null, null);
        assertDeliveryWorkReferences(clientDeliveries[toSettle + 1], index + 1, null, null);

        // Update the server with the changes
        pumpClientToServer();

        // Verify server delivery is now remotelySettled, its Link and Work entries are NOT yet clear, but tpWork IS clear
        DeliveryImpl dSettleServer = serverDeliveries[toSettle];
        assertTrue("Delivery should have been remotely settled on server", dSettleServer.remotelySettled());

        assertDeliveryLinkReferences(dSettleServer, null, serverDeliveries[toSettle - 1], serverDeliveries[toSettle+1]);
        assertDeliveryWorkReferences(dSettleServer, null, serverDeliveries[toSettle - 1], serverDeliveries[toSettle+1]);
        assertDeliveryTransportWorkReferences(dSettleServer, null, null, null);

        assertNull("expected client connection tpWork head to now be null", ((ConnectionImpl) clientConnection).getTransportWorkHead());

        // Settle on server, expect Link and Work list entries to be cleared, tpWork to remain clear (as delivery
        // is already remotely settled). Note 'work next' returns list head if none present, so we verify that here.
        dSettleServer.settle();

        assertDeliveryLinkReferences(dSettleServer, null, null, null);
        assertNull("Unexpected workPrev", dSettleServer.getWorkPrev());
        assertSame("Unexpected workNext", serverDeliveries[0], dSettleServer.getWorkNext());
        assertDeliveryTransportWorkReferences(dSettleServer, null, null, null);

        assertNull("expected server connection tpWork head to still be null", ((ConnectionImpl) serverConnection).getTransportWorkHead());

        // Verify the server entries are correct for neighbouring deliveries updated to reflect the settle
        assertDeliveryLinkReferences(serverDeliveries[toSettle - 1], null, serverDeliveries[toSettle - 2], serverDeliveries[toSettle + 1]);
        assertDeliveryWorkReferences(serverDeliveries[toSettle - 1], null, serverDeliveries[toSettle - 2], serverDeliveries[toSettle + 1]);
        assertDeliveryTransportWorkReferences(serverDeliveries[toSettle - 1], null, null, null);

        assertDeliveryLinkReferences(serverDeliveries[toSettle + 1], null, serverDeliveries[toSettle - 1], serverDeliveries[toSettle + 2]);
        assertDeliveryWorkReferences(serverDeliveries[toSettle + 1], null, serverDeliveries[toSettle - 1], serverDeliveries[toSettle + 2]);
        assertDeliveryTransportWorkReferences(serverDeliveries[toSettle + 1], null, null, null);
    }

    private Integer getDeliveryContextIndex(DeliveryImpl d) {
        assertNotNull("Should have had a delivery", d);
        Integer index = (Integer) d.getContext();
        assertNotNull("Should have had a context index", index);

        return index;
    }

    private void assertDeliveryWorkReferences(DeliveryImpl delivery, Integer index, DeliveryImpl deliveryWorkPrev, DeliveryImpl deliveryWorkNext) {
        assertNotNull("No delivery given", delivery);
        if(index != null) {
            assertEquals("Unexpected context index", Integer.valueOf(index), getDeliveryContextIndex(delivery));
        }

        if(deliveryWorkPrev == null) {
            assertNull("Unexpected workPrev", delivery.getWorkPrev());
        } else {
            assertSame("Unexpected workPrev", deliveryWorkPrev, delivery.getWorkPrev());
            assertSame("Unexpected workNext on previous delivery", delivery, deliveryWorkPrev.getWorkNext());
        }

        if(deliveryWorkNext == null) {
            assertNull("Unexpected workNext", delivery.getWorkNext());
        } else {
            assertSame("Unexpected workNext", deliveryWorkNext, delivery.getWorkNext());
            assertSame("Unexpected workPrev on next delivery",  delivery , deliveryWorkNext.getWorkPrev());
        }
    }

    private void assertDeliveryTransportWorkReferences(DeliveryImpl delivery, Integer index, DeliveryImpl deliveryTpWorkPrev, DeliveryImpl deliveryTpWorkNext) {
        assertNotNull("No delivery given", delivery);
        if(index != null) {
            assertEquals("Unexpected context index", Integer.valueOf(index), getDeliveryContextIndex(delivery));
        }

        if(deliveryTpWorkPrev == null) {
            assertNull("Unexpected transportWorkPrev", delivery.getTransportWorkPrev());
        } else {
            assertSame("Unexpected transportWorkPrev", deliveryTpWorkPrev, delivery.getTransportWorkPrev());
            assertSame("Unexpected transportWorkNext on previous delivery", delivery, deliveryTpWorkPrev.getTransportWorkNext());
        }

        if (deliveryTpWorkNext == null) {
            assertNull("Unexpected transportWorkNext", delivery.getTransportWorkNext());
        } else {
            assertSame("Unexpected transportWorkNext", deliveryTpWorkNext, delivery.getTransportWorkNext());
            assertSame("Unexpected transportWorkPrev on next delivery",  delivery , deliveryTpWorkNext.getTransportWorkPrev());
        }
    }

    private void assertDeliveryLinkReferences(DeliveryImpl delivery, Integer index, DeliveryImpl deliveryLinkPrev, DeliveryImpl deliveryLinkNext) {
        assertNotNull("No delivery given", delivery);
        if(index != null) {
            assertEquals("Unexpected context index", Integer.valueOf(index), getDeliveryContextIndex(delivery));
        }

        if(deliveryLinkPrev == null) {
            assertNull("Unexpected linkPrev", delivery.getLinkPrevious());
        } else {
            assertSame("Unexpected linkPrev", deliveryLinkPrev, delivery.getLinkPrevious());
            assertSame("Unexpected linkPrev on previous delivery", delivery, deliveryLinkPrev.getLinkNext());
        }

        if(deliveryLinkNext == null) {
            assertNull("Unexpected linkNext", delivery.getLinkNext());
        } else {
            assertSame("Unexpected linkNext", deliveryLinkNext, delivery.getLinkNext());
            assertSame("Unexpected linkPrev on next delivery",  delivery , deliveryLinkNext.getLinkPrevious());
        }
    }

    private DeliveryImpl[] receiveMessagesFromServer(int count) {
        DeliveryImpl[] deliveries = new DeliveryImpl[count];
        for(int i = 0; i < count; i++) {
            deliveries[i] = (DeliveryImpl) receiveMessageFromServer("Message" + i, i);
        }

        return deliveries;
    }

    private Delivery receiveMessageFromServer(String deliveryTag, int count)
    {
        Delivery delivery = getClient().getConnection().getWorkHead();
        Receiver clientReceiver = getClient().getReceiver();

        assertTrue(Arrays.equals(deliveryTag.getBytes(StandardCharsets.UTF_8), delivery.getTag()));
        assertEquals("The received delivery should be on our receiver",
                clientReceiver, delivery.getLink());

        assertNull(delivery.getLocalState());
        assertNull(delivery.getRemoteState());

        assertFalse(delivery.isPartial());
        assertTrue(delivery.isReadable());

        int size = delivery.available();
        byte[] received = new byte[size];

        int len = clientReceiver.recv(received, 0, size);

        assertEquals("Should have received " + size + " bytes", size, len);
        assertEquals("Should be no bytes left", 0, delivery.available());

        Message m = Proton.message();
        m.decode(received, 0, len);

        Object messageBody = ((AmqpValue)m.getBody()).getValue();
        assertEquals("Unexpected message content", count, messageBody);

        boolean receiverAdvanced = clientReceiver.advance();
        assertTrue("receiver has not advanced", receiverAdvanced);

        delivery.setContext(count);

        return delivery;
    }

    private DeliveryImpl[] sendMessagesToClient(int count) {
        DeliveryImpl[] deliveries = new DeliveryImpl[count];
        for(int i = 0; i< count; i++) {
            deliveries[i] = (DeliveryImpl) sendMessageToClient("Message" + i, i);
        }

        return deliveries;
    }

    private Delivery sendMessageToClient(String deliveryTag, int messageBody)
    {
        byte[] tag = deliveryTag.getBytes(StandardCharsets.UTF_8);

        Message m = Proton.message();
        m.setBody(new AmqpValue(messageBody));

        byte[] encoded = new byte[BUFFER_SIZE];
        int len = m.encode(encoded, 0, BUFFER_SIZE);

        assertTrue("given array was too small", len < BUFFER_SIZE);

        Sender serverSender = getServer().getSender();
        Delivery serverDelivery = serverSender.delivery(tag);
        int sent = serverSender.send(encoded, 0, len);

        assertEquals("sender unable to send all data at once as assumed for simplicity", len, sent);

        boolean senderAdvanced = serverSender.advance();
        assertTrue("sender has not advanced", senderAdvanced);

        return serverDelivery;
    }
}