/*
 * 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.pulsar.websocket;

import static com.google.common.base.Preconditions.checkArgument;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertTrue;
import com.google.common.base.Splitter;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import lombok.Cleanup;
import lombok.Getter;
import org.apache.pulsar.broker.authentication.AuthenticationDataSource;
import org.apache.pulsar.client.api.CompressionType;
import org.apache.pulsar.client.api.HashingScheme;
import org.apache.pulsar.client.api.MessageRoutingMode;
import org.apache.pulsar.client.api.PulsarClient;
import org.apache.pulsar.client.api.PulsarClientException;
import org.apache.pulsar.client.api.SubscriptionMode;
import org.apache.pulsar.client.api.SubscriptionType;
import org.apache.pulsar.client.impl.ConsumerBuilderImpl;
import org.apache.pulsar.client.impl.ProducerBuilderImpl;
import org.apache.pulsar.client.impl.conf.ConsumerConfigurationData;
import org.apache.pulsar.client.impl.conf.ProducerConfigurationData;
import org.apache.pulsar.common.naming.TopicName;
import org.apache.pulsar.common.util.Codec;
import org.apache.pulsar.websocket.service.WebSocketProxyConfiguration;
import org.eclipse.jetty.ee8.websocket.api.RemoteEndpoint;
import org.eclipse.jetty.ee8.websocket.api.Session;
import org.eclipse.jetty.ee8.websocket.server.JettyServerUpgradeResponse;
import org.eclipse.jetty.http.HttpStatus;
import org.mockito.Answers;
import org.mockito.Mock;
import org.testng.annotations.AfterClass;
import org.testng.annotations.Test;

public class AbstractWebSocketHandlerTest {
    List<PulsarClient> createdClients = Collections.synchronizedList(new ArrayList<>());

    @Mock
    private HttpServletRequest httpServletRequest;

    @AfterClass(alwaysRun = true)
    final void cleanupCreatedClients() {
        for (PulsarClient createdClient : createdClients) {
            try {
                createdClient.shutdown();
            } catch (PulsarClientException e) {
                // ignore
            }
        }
        createdClients.clear();
    }

    @Test
    public void topicNameUrlEncodingTest() throws Exception {
        String producerV2 = "/ws/v2/producer/persistent/my-property/my-ns/";
        String producerV2Topic = "my-topic[]<>";
        String consumerV2 = "/ws/v2/consumer/persistent/my-property/my-ns/";
        String consumerV2Topic = "my-topic";
        String consumerV2Sub = "my-subscription[][]<>";
        String readerV2 = "/ws/v2/reader/persistent/my-property/my-ns/";
        String readerV2Topic = "my-topic @!$#^&*()-_、`，《》<>[]";

        httpServletRequest = mock(HttpServletRequest.class);

        when(httpServletRequest.getRequestURI()).thenReturn(producerV2
                + URLEncoder.encode(producerV2Topic, StandardCharsets.UTF_8.name()));
        WebSocketHandlerImpl webSocketHandler = new WebSocketHandlerImpl(null, httpServletRequest, null);
        TopicName topicName = webSocketHandler.getTopic();
        assertEquals(topicName.toString(), "persistent://my-property/my-ns/" + producerV2Topic);

        when(httpServletRequest.getRequestURI()).thenReturn(consumerV2
                + URLEncoder.encode(consumerV2Topic, StandardCharsets.UTF_8.name()) + "/"
                + URLEncoder.encode(consumerV2Sub, StandardCharsets.UTF_8.name()));
        webSocketHandler = new WebSocketHandlerImpl(null, httpServletRequest, null);
        topicName = webSocketHandler.getTopic();
        assertEquals(topicName.toString(), "persistent://my-property/my-ns/" + consumerV2Topic);
        String sub = extractSubscription(httpServletRequest);
        assertEquals(sub, consumerV2Sub);

        when(httpServletRequest.getRequestURI()).thenReturn(readerV2
                + URLEncoder.encode(readerV2Topic, StandardCharsets.UTF_8.name()));
        webSocketHandler = new WebSocketHandlerImpl(null, httpServletRequest, null);
        topicName = webSocketHandler.getTopic();
        assertEquals(topicName.toString(), "persistent://my-property/my-ns/" + readerV2Topic);
    }

    public String extractSubscription(HttpServletRequest request) {
        String uri = request.getRequestURI();
        List<String> parts = Splitter.on("/").splitToList(uri);

        // Format must be like :
        // /ws/v2/consumer/persistent/my-property/my-ns/my-topic/my-subscription
        checkArgument(parts.size() == 9, "Invalid topic name format");
        checkArgument(parts.get(1).equals("ws"));
        checkArgument(parts.get(2).equals("v2"));

        checkArgument(parts.get(4).equals("persistent")
                || parts.get(4).equals("non-persistent"));
        checkArgument(parts.get(8).length() > 0, "Empty subscription name");

        return Codec.decode(parts.get(8));
    }

    @Test
    public void parseTopicNameTest() {
        String producerV2 = "/ws/v2/producer/persistent/my-property/my-ns/my-topic";
        String consumerV2 = "/ws/v2/consumer/persistent/my-property/my-ns/my-topic/my-subscription";
        String readerV2 = "/ws/v2/reader/persistent/my-property/my-ns/my-topic";

        httpServletRequest = mock(HttpServletRequest.class);

        when(httpServletRequest.getRequestURI()).thenReturn(producerV2);
        WebSocketHandlerImpl webSocketHandler = new WebSocketHandlerImpl(null, httpServletRequest, null);
        TopicName topicName = webSocketHandler.getTopic();
        assertEquals(topicName.toString(), "persistent://my-property/my-ns/my-topic");

        when(httpServletRequest.getRequestURI()).thenReturn(consumerV2);
        webSocketHandler = new WebSocketHandlerImpl(null, httpServletRequest, null);
        topicName = webSocketHandler.getTopic();
        assertEquals(topicName.toString(), "persistent://my-property/my-ns/my-topic");

        when(httpServletRequest.getRequestURI()).thenReturn(readerV2);
        webSocketHandler = new WebSocketHandlerImpl(null, httpServletRequest, null);
        topicName = webSocketHandler.getTopic();
        assertEquals(topicName.toString(), "persistent://my-property/my-ns/my-topic");

    }

    static class WebSocketHandlerImpl extends AbstractWebSocketHandler {

        public WebSocketHandlerImpl(WebSocketService service, HttpServletRequest request,
                                    JettyServerUpgradeResponse response) {
            super(service, request, response);
            allowConnect = true;
        }

        @Override
        protected Boolean isAuthorized(String authRole, AuthenticationDataSource authenticationData) {
            return null;
        }

        @Override
        public void close() throws IOException {

        }

        public TopicName getTopic() {
            return super.topic;
        }

    }

    abstract static class MockedServletUpgradeResponse implements JettyServerUpgradeResponse {

        @Getter
        private int statusCode;
        @Getter
        private String message;

        public MockedServletUpgradeResponse() {

        }

        public void sendError(int statusCode, String message) {
            this.statusCode = statusCode;
            this.message = message;
        }
    }

    PulsarClient newPulsarClient() throws PulsarClientException {
        PulsarClient client = PulsarClient.builder()
                .serviceUrl("pulsar://localhost:6650")
                .operationTimeout(1, TimeUnit.SECONDS)
                .build();
        createdClients.add(client);
        return client;
    }

    class MockedProducerHandler extends ProducerHandler {

        public MockedProducerHandler(WebSocketService service, HttpServletRequest request,
                                     JettyServerUpgradeResponse response) {
            super(service, request, response);
        }

        public ProducerConfigurationData getConf() throws PulsarClientException {
            return ((ProducerBuilderImpl<byte[]>) getProducerBuilder(newPulsarClient())).getConf();
        }

        public void clearQueryParams() {
            queryParams.clear();
        }

        public void putQueryParam(String key, String value) {
            queryParams.put(key, value);
        }
    }

    @Test
    public void producerBuilderTest() throws IOException {
        String producerV2 = "/ws/v2/producer/persistent/my-property/my-ns/my-topic";
        // the params are all different with the default value
        Map<String, String[]> queryParams = new HashMap<String, String>(){{
            put("producerName", "my-producer");
            put("initialSequenceId", "1");
            put("hashingScheme", "Murmur3_32Hash");
            put("sendTimeoutMillis", "30001");
            put("batchingEnabled", "true");
            put("batchingMaxMessages", "1001");
            put("maxPendingMessages", "1001");
            put("batchingMaxPublishDelay", "2");
            put("messageRoutingMode", "RoundRobinPartition");
            put("compressionType", "LZ4");
        }}.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, entry -> new String[]{ entry.getValue() }));

        httpServletRequest = mock(HttpServletRequest.class);
        when(httpServletRequest.getRequestURI()).thenReturn(producerV2);
        when(httpServletRequest.getParameterMap()).thenReturn(queryParams);

        WebSocketService service = mock(WebSocketService.class);
        when(service.isAuthenticationEnabled()).thenReturn(false);
        when(service.isAuthorizationEnabled()).thenReturn(false);
        when(service.getPulsarClient()).thenReturn(newPulsarClient());

        MockedServletUpgradeResponse response = mock(MockedServletUpgradeResponse.class, Answers.CALLS_REAL_METHODS);

        MockedProducerHandler producerHandler = new MockedProducerHandler(service, httpServletRequest, response);
        assertEquals(response.getStatusCode(), 500);
        assertTrue(response.getMessage().contains("Connection refused"));

        ProducerConfigurationData conf = producerHandler.getConf();
        assertEquals(conf.getProducerName(), "my-producer");
        assertEquals(conf.getInitialSequenceId().longValue(), 1L);
        assertEquals(conf.getHashingScheme(), HashingScheme.Murmur3_32Hash);
        assertEquals(conf.getSendTimeoutMs(), 30001);
        assertTrue(conf.isBatchingEnabled());
        assertEquals(conf.getBatchingMaxMessages(), 1001);
        assertEquals(conf.getMaxPendingMessages(), 1001);
        assertEquals(conf.getBatchingMaxPublishDelayMicros(), 2000);
        assertEquals(conf.getMessageRoutingMode(), MessageRoutingMode.RoundRobinPartition);
        assertEquals(conf.getCompressionType(), CompressionType.LZ4);

        producerHandler.clearQueryParams();
        conf = producerHandler.getConf();
        // By default batching is disabled, which is different with ProducerBuilder
        assertFalse(conf.isBatchingEnabled());

        // The default message routing mode is SinglePartition, which is different with ProducerBuilder
        assertEquals(conf.getMessageRoutingMode(), MessageRoutingMode.SinglePartition);

        producerHandler.putQueryParam("messageRoutingMode", "CustomPartition");
        conf = producerHandler.getConf();
        // ProducerHandler doesn't support CustomPartition
        assertEquals(conf.getMessageRoutingMode(), MessageRoutingMode.SinglePartition);
    }

    class MockedConsumerHandler extends ConsumerHandler {

        public MockedConsumerHandler(WebSocketService service, HttpServletRequest request,
                                     JettyServerUpgradeResponse response) {
            super(service, request, response);
        }

        public ConsumerConfigurationData<byte[]> getConf() throws PulsarClientException {
            return ((ConsumerBuilderImpl<byte[]>) getConsumerConfiguration(newPulsarClient())).getConf();
        }

        public void clearQueryParams() {
            queryParams.clear();
        }

        public void putQueryParam(String key, String value) {
            queryParams.put(key, value);
        }
    }

    @Test
    public void consumerBuilderTest() throws IOException {
        String consumerV2 = "/ws/v2/consumer/persistent/my-property/my-ns/my-topic/my-subscription";
        // the params are all different with the default value
        Map<String, String[]> queryParams = new HashMap<String, String>(){{
            put("ackTimeoutMillis", "1001");
            put("subscriptionType", "Key_Shared");
            put("subscriptionMode", "NonDurable");
            put("receiverQueueSize", "999");
            put("consumerName", "my-consumer");
            put("priorityLevel", "1");
            put("maxRedeliverCount", "5");
        }}.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, entry -> new String[]{ entry.getValue() }));

        httpServletRequest = mock(HttpServletRequest.class);
        when(httpServletRequest.getRequestURI()).thenReturn(consumerV2);
        when(httpServletRequest.getParameterMap()).thenReturn(queryParams);

        WebSocketService service = mock(WebSocketService.class);
        when(service.isAuthenticationEnabled()).thenReturn(false);
        when(service.isAuthorizationEnabled()).thenReturn(false);
        when(service.getPulsarClient()).thenReturn(newPulsarClient());

        MockedServletUpgradeResponse response = mock(MockedServletUpgradeResponse.class, Answers.CALLS_REAL_METHODS);

        MockedConsumerHandler consumerHandler = new MockedConsumerHandler(service, httpServletRequest, response);
        assertEquals(response.getStatusCode(), 500);
        assertTrue(response.getMessage().contains("Connection refused"));
        assertEquals(consumerHandler.getSubscriptionMode(), SubscriptionMode.NonDurable);
        assertEquals(consumerHandler.getSubscriptionType(), SubscriptionType.Key_Shared);

        ConsumerConfigurationData<byte[]> conf = consumerHandler.getConf();
        assertEquals(conf.getAckTimeoutMillis(), 1001);
        assertEquals(conf.getSubscriptionType(), SubscriptionType.Key_Shared);
        assertEquals(conf.getSubscriptionMode(), SubscriptionMode.NonDurable);
        assertEquals(conf.getReceiverQueueSize(), 999);
        assertEquals(conf.getConsumerName(), "my-consumer");
        assertEquals(conf.getPriorityLevel(), 1);
        assertEquals(conf.getDeadLetterPolicy().getDeadLetterTopic(),
                "persistent://my-property/my-ns/my-topic-my-subscription-DLQ");
        assertEquals(conf.getDeadLetterPolicy().getMaxRedeliverCount(), 5);

        consumerHandler.clearQueryParams();
        consumerHandler.putQueryParam("receiverQueueSize", "1001");
        consumerHandler.putQueryParam("deadLetterTopic", "dead-letter-topic");
        consumerHandler.putQueryParam("maxRedeliverCount", "3");

        conf = consumerHandler.getConf();
        // receive queue size is the minimum value of default value (1000) and user defined value(1001)
        assertEquals(conf.getReceiverQueueSize(), 1000);
        assertEquals(conf.getDeadLetterPolicy().getDeadLetterTopic(), "dead-letter-topic");
        assertEquals(conf.getDeadLetterPolicy().getMaxRedeliverCount(), 3);
    }

    @Test
    public void testPingFuture() throws IOException {
        WebSocketProxyConfiguration webSocketProxyConfiguration = new WebSocketProxyConfiguration();
        webSocketProxyConfiguration.setWebSocketPingDurationSeconds(5);

        @Cleanup
        WebSocketService webSocketService = new WebSocketService(webSocketProxyConfiguration);

        HttpServletRequest httpServletRequest = mock(HttpServletRequest.class);
        String consumerV2 = "/ws/v2/consumer/persistent/my-property/my-ns/my-topic/my-subscription";
        Map<String, String[]> queryParams = new HashMap<String, String>(){{
            put("ackTimeoutMillis", "1001");
            put("subscriptionType", "Key_Shared");
            put("subscriptionMode", "NonDurable");
            put("receiverQueueSize", "999");
            put("consumerName", "my-consumer");
            put("priorityLevel", "1");
            put("maxRedeliverCount", "5");
        }}.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, entry -> new String[]{ entry.getValue() }));

        when(httpServletRequest.getRequestURI()).thenReturn(consumerV2);
        when(httpServletRequest.getParameterMap()).thenReturn(queryParams);

        MockedServletUpgradeResponse response = mock(MockedServletUpgradeResponse.class, Answers.CALLS_REAL_METHODS);
        AbstractWebSocketHandler webSocketHandler =
                new WebSocketHandlerImpl(webSocketService, httpServletRequest, response);

        Session session = mock(Session.class);
        RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);
        when(session.getRemote()).thenReturn(remoteEndpoint);

        // onWebSocketClose
        webSocketHandler.onWebSocketConnect(session);

        ScheduledFuture<?> pingFuture = webSocketHandler.getPingFuture();
        assertNotNull(pingFuture);
        assertFalse(pingFuture.isDone());

        webSocketHandler.onWebSocketClose(HttpStatus.INTERNAL_SERVER_ERROR_500, "INTERNAL_SERVER_ERROR_500");
        assertTrue(pingFuture.isDone());


        // onWebSocketError
        webSocketHandler.onWebSocketConnect(session);

        pingFuture = webSocketHandler.getPingFuture();
        assertNotNull(pingFuture);
        assertFalse(pingFuture.isDone());

        webSocketHandler.onWebSocketError(new RuntimeException("INTERNAL_SERVER_ERROR_500"));
        assertTrue(pingFuture.isDone());
    }
}
