/*
 * 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.nifi.remote;

import org.apache.nifi.connectable.ConnectableType;
import org.apache.nifi.controller.ProcessScheduler;
import org.apache.nifi.events.EventReporter;
import org.apache.nifi.flowfile.FlowFile;
import org.apache.nifi.flowfile.attributes.CoreAttributes;
import org.apache.nifi.flowfile.attributes.SiteToSiteAttributes;
import org.apache.nifi.groups.ProcessGroup;
import org.apache.nifi.groups.RemoteProcessGroup;
import org.apache.nifi.processor.Processor;
import org.apache.nifi.processor.Relationship;
import org.apache.nifi.provenance.ProvenanceEventRecord;
import org.apache.nifi.provenance.ProvenanceEventType;
import org.apache.nifi.remote.client.SiteToSiteClient;
import org.apache.nifi.remote.client.SiteToSiteClientConfig;
import org.apache.nifi.remote.io.http.HttpCommunicationsSession;
import org.apache.nifi.remote.io.socket.SocketCommunicationsSession;
import org.apache.nifi.remote.protocol.CommunicationsSession;
import org.apache.nifi.remote.protocol.DataPacket;
import org.apache.nifi.remote.protocol.SiteToSiteTransportProtocol;
import org.apache.nifi.remote.util.StandardDataPacket;
import org.apache.nifi.util.MockFlowFile;
import org.apache.nifi.util.MockProcessContext;
import org.apache.nifi.util.MockProcessSession;
import org.apache.nifi.util.NiFiProperties;
import org.apache.nifi.util.SharedSessionState;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import java.io.ByteArrayInputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.IntStream;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;

public class TestStandardRemoteGroupPort {

    private static final String ID = "remote-group-port-id";
    private static final String NAME = "remote-group-port-name";

    private RemoteProcessGroup remoteGroup;
    private ProcessScheduler scheduler;
    private SiteToSiteClient siteToSiteClient;
    private Transaction transaction;
    private EventReporter eventReporter;
    private ProcessGroup processGroup;
    private static final String REMOTE_CLUSTER_URL = "http://node0.example.com:8080/nifi";
    private StandardRemoteGroupPort port;
    private SharedSessionState sessionState;
    private MockProcessSession processSession;
    private MockProcessContext processContext;

    @BeforeAll
    public static void setup() throws Exception {
        System.setProperty(NiFiProperties.PROPERTIES_FILE_PATH, "src/test/resources/nifi.properties");
        System.setProperty("org.slf4j.simpleLogger.log.org.apache.nifi.remote", "DEBUG");
    }

    private void setupMock(final SiteToSiteTransportProtocol protocol,
            final TransferDirection direction) throws Exception {
        final SiteToSiteClientConfig siteToSiteClientConfig = new SiteToSiteClient.Builder().buildConfig();
        setupMock(protocol, direction, siteToSiteClientConfig);
    }

    private void setupMock(final SiteToSiteTransportProtocol protocol,
            final TransferDirection direction,
           final SiteToSiteClientConfig siteToSiteClientConfig) throws Exception {
        processGroup = null;
        remoteGroup = mock(RemoteProcessGroup.class);
        scheduler = null;
        siteToSiteClient = mock(SiteToSiteClient.class);
        this.transaction = mock(Transaction.class);

        eventReporter = mock(EventReporter.class);

        final ConnectableType connectableType;
        switch (direction) {
            case SEND:
                connectableType = ConnectableType.REMOTE_INPUT_PORT;
                break;
            case RECEIVE:
                connectableType = ConnectableType.OUTPUT_PORT;
                break;
            default:
                connectableType = null;
                break;
        }

        port = spy(new StandardRemoteGroupPort(ID, ID, NAME, remoteGroup, direction, connectableType, null, scheduler));

        doReturn(true).when(remoteGroup).isTransmitting();
        doReturn(protocol).when(remoteGroup).getTransportProtocol();
        doReturn(REMOTE_CLUSTER_URL).when(remoteGroup).getTargetUri();
        doReturn(siteToSiteClient).when(port).getSiteToSiteClient();
        doReturn(transaction).when(siteToSiteClient).createTransaction(eq(direction));
        doReturn(siteToSiteClientConfig).when(siteToSiteClient).getConfig();
        doReturn(eventReporter).when(remoteGroup).getEventReporter();

    }

    private void setupMockProcessSession() {
        // Construct a RemoteGroupPort as a processor to use NiFi mock library.
        final Processor remoteGroupPort = mock(Processor.class);
        final Set<Relationship> relationships = new HashSet<>();
        relationships.add(Relationship.ANONYMOUS);
        when(remoteGroupPort.getRelationships()).thenReturn(relationships);
        when(remoteGroupPort.getIdentifier()).thenReturn("remote-group-port-id");

        sessionState = new SharedSessionState(remoteGroupPort, new AtomicLong(0));
        processSession = new MockProcessSession(sessionState, remoteGroupPort);
        processContext = new MockProcessContext(remoteGroupPort);
    }

    @Test
    public void testSendRaw() throws Exception {

        setupMock(SiteToSiteTransportProtocol.RAW, TransferDirection.SEND);
        setupMockProcessSession();

        final String peerUrl = "nifi://node1.example.com:9090";
        final PeerDescription peerDescription = new PeerDescription("node1.example.com", 9090, true);
        final CommunicationsSession commsSession =  mock(SocketCommunicationsSession.class);
        when(commsSession.createTransitUri(anyString(), anyString())).thenReturn("nifi://node1.example.com:9090/flowfile-uuid");
        when(commsSession.getUserDn()).thenReturn("nifi.node1.example.com");
        final Peer peer = new Peer(peerDescription, commsSession, peerUrl, REMOTE_CLUSTER_URL);

        doReturn(peer).when(transaction).getCommunicant();

        final MockFlowFile flowFile = processSession.createFlowFile("0123456789".getBytes());
        sessionState.getFlowFileQueue().offer(flowFile);

        port.onTrigger(processContext, processSession);

        // Assert provenance.
        final List<ProvenanceEventRecord> provenanceEvents = sessionState.getProvenanceEvents();
        assertEquals(2, provenanceEvents.size());
        final ProvenanceEventRecord provenanceEvent = provenanceEvents.get(0);
        assertEquals(ProvenanceEventType.SEND, provenanceEvent.getEventType());
        assertEquals("nifi://node1.example.com:9090/flowfile-uuid", provenanceEvent.getTransitUri());
        assertEquals("Remote DN=nifi.node1.example.com", provenanceEvent.getDetails());
        assertEquals("remote-group-port-id", provenanceEvent.getAttribute(SiteToSiteAttributes.S2S_PORT_ID.key()));

    }

    @Test
    public void testReceiveRaw() throws Exception {

        setupMock(SiteToSiteTransportProtocol.RAW, TransferDirection.RECEIVE);
        setupMockProcessSession();

        final String peerUrl = "nifi://node1.example.com:9090";
        final PeerDescription peerDescription = new PeerDescription("node1.example.com", 9090, true);
        final CommunicationsSession commsSession =  mock(SocketCommunicationsSession.class);
        when(commsSession.createTransitUri(anyString(), anyString())).thenReturn("nifi://node1.example.com:9090/flowfile-uuid");
        when(commsSession.getUserDn()).thenReturn("nifi.node1.example.com");
        final Peer peer = new Peer(peerDescription, commsSession, peerUrl, REMOTE_CLUSTER_URL);

        doReturn(peer).when(transaction).getCommunicant();

        final String sourceFlowFileUuid = "flowfile-uuid";
        final Map<String, String> attributes = new HashMap<>();
        attributes.put(CoreAttributes.UUID.key(), sourceFlowFileUuid);
        final byte[] dataPacketContents = "DataPacket Contents".getBytes();
        final ByteArrayInputStream dataPacketInputStream = new ByteArrayInputStream(dataPacketContents);
        final DataPacket dataPacket = new StandardDataPacket(attributes,
                dataPacketInputStream, dataPacketContents.length);

        // Return null when it gets called second time.
        doReturn(dataPacket).doReturn(null).when(this.transaction).receive();

        port.onTrigger(processContext, processSession);

        // Assert provenance.
        final List<ProvenanceEventRecord> provenanceEvents = sessionState.getProvenanceEvents();
        assertEquals(1, provenanceEvents.size());
        final ProvenanceEventRecord provenanceEvent = provenanceEvents.get(0);
        assertEquals(ProvenanceEventType.RECEIVE, provenanceEvent.getEventType());
        assertEquals("nifi://node1.example.com:9090/flowfile-uuid", provenanceEvent.getTransitUri());
        assertEquals("Remote DN=nifi.node1.example.com", provenanceEvent.getDetails());

        // Assert received flow files.
        processSession.assertAllFlowFilesTransferred(Relationship.ANONYMOUS);
        final List<MockFlowFile> flowFiles = processSession.getFlowFilesForRelationship(Relationship.ANONYMOUS);
        assertEquals(1, flowFiles.size());
        final MockFlowFile flowFile = flowFiles.get(0);
        flowFile.assertAttributeEquals(SiteToSiteAttributes.S2S_HOST.key(), peer.getHost());
        flowFile.assertAttributeEquals(SiteToSiteAttributes.S2S_ADDRESS.key(), peer.getHost() + ":" + peer.getPort());
        flowFile.assertAttributeEquals(SiteToSiteAttributes.S2S_PORT_ID.key(), "remote-group-port-id");

    }

    @Test
    public void testSendHttp() throws Exception {

        setupMock(SiteToSiteTransportProtocol.HTTP, TransferDirection.SEND);
        setupMockProcessSession();

        final String peerUrl = "https://node1.example.com:8080/nifi";
        final PeerDescription peerDescription = new PeerDescription("node1.example.com", 8080, true);
        final HttpCommunicationsSession commsSession = new HttpCommunicationsSession();
        commsSession.setUserDn("nifi.node1.example.com");
        final Peer peer = new Peer(peerDescription, commsSession, peerUrl, REMOTE_CLUSTER_URL);

        final String flowFileEndpointUri = "https://node1.example.com:8080/nifi-api/output-ports/port-id/transactions/transaction-id/flow-files";

        doReturn(peer).when(transaction).getCommunicant();
        commsSession.setDataTransferUrl(flowFileEndpointUri);

        final MockFlowFile flowFile = processSession.createFlowFile("0123456789".getBytes());
        sessionState.getFlowFileQueue().offer(flowFile);

        port.onTrigger(processContext, processSession);

        // Assert provenance.
        final List<ProvenanceEventRecord> provenanceEvents = sessionState.getProvenanceEvents();
        assertEquals(2, provenanceEvents.size());
        final ProvenanceEventRecord provenanceEvent = provenanceEvents.get(0);
        assertEquals(ProvenanceEventType.SEND, provenanceEvent.getEventType());
        assertEquals(flowFileEndpointUri, provenanceEvent.getTransitUri());
        assertEquals("Remote DN=nifi.node1.example.com", provenanceEvent.getDetails());
        assertEquals("remote-group-port-id", provenanceEvent.getAttribute(SiteToSiteAttributes.S2S_PORT_ID.key()));
    }

    @Test
    public void testSendBatchByCount() throws Exception {

        final SiteToSiteClientConfig siteToSiteClientConfig = new SiteToSiteClient.Builder()
                .requestBatchCount(2)
                .buildConfig();
        setupMock(SiteToSiteTransportProtocol.HTTP, TransferDirection.SEND, siteToSiteClientConfig);

        // t1 = {0, 1}, t2 = {2, 3}, t3 = {4}
        final int[] expectedNumberOfPackets = {2, 2, 1};
        testSendBatch(expectedNumberOfPackets);

    }

    @Test
    public void testSendBatchBySize() throws Exception {

        final SiteToSiteClientConfig siteToSiteClientConfig = new SiteToSiteClient.Builder()
                .requestBatchSize(30)
                .buildConfig();
        setupMock(SiteToSiteTransportProtocol.HTTP, TransferDirection.SEND, siteToSiteClientConfig);

        // t1 = {10, 11, 12}, t2 = {13, 14}
        final int[] expectedNumberOfPackets = {3, 2};
        testSendBatch(expectedNumberOfPackets);

    }

    @Test
    public void testSendBatchByDuration() throws Exception {

        final SiteToSiteClientConfig siteToSiteClientConfig = new SiteToSiteClient.Builder()
                .requestBatchDuration(1, TimeUnit.NANOSECONDS)
                .buildConfig();
        setupMock(SiteToSiteTransportProtocol.HTTP, TransferDirection.SEND, siteToSiteClientConfig);

        // t1 = {1}, t2 = {2} .. and so on.
        final int[] expectedNumberOfPackets = {1, 1, 1, 1, 1};
        testSendBatch(expectedNumberOfPackets);

    }

    /**
     * Generate flow files to be sent, and execute port's onTrigger method.
     * Finally, this method verifies whether packets are sent as expected.
     * @param expectedNumberOfPackets Specify how many packets should be sent by each transaction.
     *                                E.g. passing {2, 2, 1}, would generate 5 flow files in total.
     *                                Based on the siteToSiteClientConfig batch parameters,
     *                                it's expected to be sent via 3 transactions,
     *                                transaction 0 will send flow file 0 and 1,
     *                                transaction 1 will send flow file 2 and 3,
     *                                and transaction 2 will send flow file 4.
     *                                Each flow file has different content size generated automatically.
     *                                The content size starts with 10, and increases as more flow files are generated.
     *                                E.g. flow file 1 will have 10 bytes, flow file 2 has 11 bytes, f3 has 12 and so on.
     *
     */
    private void testSendBatch(final int[] expectedNumberOfPackets) throws Exception {

        setupMockProcessSession();

        final String peerUrl = "http://node1.example.com:8080/nifi";
        final PeerDescription peerDescription = new PeerDescription("node1.example.com", 8080, false);
        final HttpCommunicationsSession commsSession = new HttpCommunicationsSession();
        final Peer peer = new Peer(peerDescription, commsSession, peerUrl, REMOTE_CLUSTER_URL);

        final String flowFileEndpointUri = "http://node1.example.com:8080/nifi-api/output-ports/port-id/transactions/transaction-id/flow-files";

        doReturn(peer).when(transaction).getCommunicant();
        commsSession.setDataTransferUrl(flowFileEndpointUri);

        // Capture packets being sent to the remote peer
        final AtomicInteger totalPacketsSent = new AtomicInteger(0);
        final List<List<DataPacket>> sentPackets = new ArrayList<>(expectedNumberOfPackets.length);
        final List<DataPacket> sentPacketsPerTransaction = new ArrayList<>();
        doAnswer(invocation -> {
            sentPacketsPerTransaction.add((DataPacket)invocation.getArguments()[0]);
            totalPacketsSent.incrementAndGet();
            return null;
        }).when(transaction).send(any(DataPacket.class));
        doAnswer(invocation -> {
            sentPackets.add(new ArrayList<>(sentPacketsPerTransaction));
            sentPacketsPerTransaction.clear();
            return null;
        }).when(transaction).confirm();


        // Execute onTrigger while offering new flow files.
        final List<MockFlowFile> flowFiles = new ArrayList<>();
        for (int i = 0; i < expectedNumberOfPackets.length; i++) {
            int numOfPackets = expectedNumberOfPackets[i];
            int startF = flowFiles.size();
            int endF = startF + numOfPackets;
            IntStream.range(startF, endF).forEach(f -> {
                final StringBuilder flowFileContents = new StringBuilder("0123456789");
                for (int c = 0; c < f; c++) {
                    flowFileContents.append(c);
                }
                final byte[] bytes = flowFileContents.toString().getBytes();
                final MockFlowFile flowFile = spy(processSession.createFlowFile(bytes));
                when(flowFile.getSize()).then(invocation -> {
                    Thread.sleep(1); // For testSendBatchByDuration
                    // Array.length returns int, but flowFile.getSize() must return a long
                    return Integer.valueOf(bytes.length).longValue();
                });
                sessionState.getFlowFileQueue().offer(flowFile);
                flowFiles.add(flowFile);
            });
            port.onTrigger(processContext, processSession);
        }

        // Verify transactions, sent packets, and provenance events.
        assertEquals(flowFiles.size(), totalPacketsSent.get());
        assertEquals(expectedNumberOfPackets.length, sentPackets.size(),"The number of transactions should match as expected.");
        final List<ProvenanceEventRecord> provenanceEvents = sessionState.getProvenanceEvents();
        // SEND and DROP events for each flowfile
        assertEquals(flowFiles.size() * 2, provenanceEvents.size());

        int f = 0;
        for (int i = 0; i < expectedNumberOfPackets.length; i++) {
            final List<DataPacket> dataPackets = sentPackets.get(i);
            assertEquals(expectedNumberOfPackets[i], dataPackets.size());

            for (int p = 0; p < dataPackets.size(); p++) {
                final FlowFile flowFile = flowFiles.get(f);

                // Assert sent packet
                final DataPacket dataPacket = dataPackets.get(p);
                assertEquals(flowFile.getSize(), dataPacket.getSize());

                // Assert provenance events (SEND and DROP)
                ProvenanceEventRecord provenanceEvent = provenanceEvents.get(f * 2);
                assertEquals(ProvenanceEventType.SEND, provenanceEvent.getEventType());
                assertEquals(flowFileEndpointUri, provenanceEvent.getTransitUri());
                provenanceEvent = provenanceEvents.get(f * 2 + 1);
                assertEquals(ProvenanceEventType.DROP, provenanceEvent.getEventType());
                f++;
            }
        }
    }

    @Test
    public void testReceiveHttp() throws Exception {

        setupMock(SiteToSiteTransportProtocol.HTTP, TransferDirection.RECEIVE);
        setupMockProcessSession();

        final String peerUrl = "https://node1.example.com:8080/nifi";
        final PeerDescription peerDescription = new PeerDescription("node1.example.com", 8080, true);
        final HttpCommunicationsSession commsSession = new HttpCommunicationsSession();
        commsSession.setUserDn("nifi.node1.example.com");
        final Peer peer = new Peer(peerDescription, commsSession, peerUrl, REMOTE_CLUSTER_URL);

        final String flowFileEndpointUri = "https://node1.example.com:8080/nifi-api/output-ports/port-id/transactions/transaction-id/flow-files";

        doReturn(peer).when(transaction).getCommunicant();
        commsSession.setDataTransferUrl(flowFileEndpointUri);

        final Map<String, String> attributes = new HashMap<>();
        final byte[] dataPacketContents = "DataPacket Contents".getBytes();
        final ByteArrayInputStream dataPacketInputStream = new ByteArrayInputStream(dataPacketContents);
        final DataPacket dataPacket = new StandardDataPacket(attributes,
                dataPacketInputStream, dataPacketContents.length);

        // Return null when it gets called second time.
        doReturn(dataPacket).doReturn(null).when(transaction).receive();

        port.onTrigger(processContext, processSession);

        // Assert provenance.
        final List<ProvenanceEventRecord> provenanceEvents = sessionState.getProvenanceEvents();
        assertEquals(1, provenanceEvents.size());
        final ProvenanceEventRecord provenanceEvent = provenanceEvents.get(0);
        assertEquals(ProvenanceEventType.RECEIVE, provenanceEvent.getEventType());
        assertEquals(flowFileEndpointUri, provenanceEvent.getTransitUri());
        assertEquals("Remote DN=nifi.node1.example.com", provenanceEvent.getDetails());

        // Assert received flow files.
        processSession.assertAllFlowFilesTransferred(Relationship.ANONYMOUS);
        final List<MockFlowFile> flowFiles = processSession.getFlowFilesForRelationship(Relationship.ANONYMOUS);
        assertEquals(1, flowFiles.size());
        final MockFlowFile flowFile = flowFiles.get(0);
        flowFile.assertAttributeEquals(SiteToSiteAttributes.S2S_HOST.key(), peer.getHost());
        flowFile.assertAttributeEquals(SiteToSiteAttributes.S2S_ADDRESS.key(), peer.getHost() + ":" + peer.getPort());
        flowFile.assertAttributeEquals(SiteToSiteAttributes.S2S_PORT_ID.key(), "remote-group-port-id");

    }
}
