package org.infinispan.server.test.client.hotrod;

import static org.infinispan.server.test.util.ITestUtils.isLocalMode;
import static org.infinispan.server.test.util.ITestUtils.sleepForSecs;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

import java.io.IOException;
import java.io.Serializable;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;

import org.infinispan.arquillian.core.RemoteInfinispanServer;
import org.infinispan.client.hotrod.Flag;
import org.infinispan.client.hotrod.MetadataValue;
import org.infinispan.client.hotrod.RemoteCache;
import org.infinispan.client.hotrod.RemoteCacheManager;
import org.infinispan.client.hotrod.ServerStatistics;
import org.infinispan.client.hotrod.VersionedValue;
import org.infinispan.client.hotrod.annotation.ClientListener;
import org.infinispan.client.hotrod.configuration.Configuration;
import org.infinispan.client.hotrod.configuration.ConfigurationBuilder;
import org.infinispan.client.hotrod.event.ClientCacheEntryCreatedEvent;
import org.infinispan.client.hotrod.event.ClientCacheEntryModifiedEvent;
import org.infinispan.client.hotrod.event.ClientCacheEntryRemovedEvent;
import org.infinispan.client.hotrod.event.ClientEvent;
import org.infinispan.client.hotrod.logging.Log;
import org.infinispan.client.hotrod.logging.LogFactory;
import org.infinispan.commons.marshall.jboss.GenericJBossMarshaller;
import org.infinispan.commons.util.CloseableIterator;
import org.infinispan.filter.KeyValueFilterConverterFactory;
import org.infinispan.notifications.cachelistener.filter.CacheEventConverterFactory;
import org.infinispan.notifications.cachelistener.filter.CacheEventFilterConverterFactory;
import org.infinispan.notifications.cachelistener.filter.CacheEventFilterFactory;
import org.jboss.shrinkwrap.api.Archive;
import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.spec.JavaArchive;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.Test;

/**
 * Tests for HotRod client and its RemoteCache API. Subclasses must provide a
 * way to get the list of remote HotRod servers and to assert the cache is
 * empty.
 * <p/>
 * Subclasses may be used in Client-Server mode or Hybrid mode where HotRod
 * server runs as a library deployed in an application server.
 *
 * @author Richard Achmatowicz
 * @author Martin Gencur
 * @author Jozef Vilkolak
 */
public abstract class AbstractRemoteCacheIT {
    private static final Log log = LogFactory.getLog(AbstractRemoteCacheIT.class);
    protected static String testCache = "testCache";
    protected static RemoteCacheManager remoteCacheManager = null;
    protected RemoteCache remoteCache;
    protected final int ASYNC_OPS_ENTRY_LOAD = 10;

    protected abstract List<RemoteInfinispanServer> getServers();

    @Before
    public void initialize() {
        if (remoteCacheManager == null) {
            Configuration config = createRemoteCacheManagerConfiguration();
            remoteCacheManager = new RemoteCacheManager(config, true);
        }
        remoteCache = remoteCacheManager.getCache(testCache);
        remoteCache.clear();
    }

    @AfterClass
    public static void release() {
        if (remoteCacheManager != null) {
            remoteCacheManager.stop();
        }
    }

    protected static Archive<?> createFilterArchive() {
        return ShrinkWrap.create(JavaArchive.class, "filter.jar")
                .addClasses(StaticCacheEventFilterFactory.class, DynamicCacheEventFilterFactory.class,
                        CustomPojoEventFilterFactory.class, Person.class)
                .addAsServiceProvider(CacheEventFilterFactory.class,
                        StaticCacheEventFilterFactory.class, DynamicCacheEventFilterFactory.class,
                        CustomPojoEventFilterFactory.class);
    }

    protected static Archive<?> createConverterArchive() {
        return ShrinkWrap.create(JavaArchive.class, "converter.jar")
                .addClasses(StaticCacheEventConverterFactory.class, DynamicCacheEventConverterFactory.class,
                        CustomPojoEventConverterFactory.class, Person.class, CustomEvent.class)
                .addAsServiceProvider(CacheEventConverterFactory.class,
                        StaticCacheEventConverterFactory.class, DynamicCacheEventConverterFactory.class,
                        CustomPojoEventConverterFactory.class);
    }

    protected static Archive<?> createFilterConverterArchive() {
        return ShrinkWrap.create(JavaArchive.class, "filter-converter.jar")
                .addClasses(FilterConverterFactory.class, CustomEvent.class,
                        CustomPojoFilterConverterFactory.class, Person.class, Id.class)
                .addAsServiceProvider(CacheEventFilterConverterFactory.class, FilterConverterFactory.class,
                        CustomPojoFilterConverterFactory.class);
    }

    protected static Archive<?> createKeyValueFilterConverterArchive() {
        return ShrinkWrap.create(JavaArchive.class, "key-value-filter-converter.jar")
                .addClasses(TestKeyValueFilterConverterFactory.class, SampleEntity.class, Summary.class, SampleEntity.SampleEntityExternalizer.class, Summary.SummaryExternalizer.class)
                .addAsServiceProvider(KeyValueFilterConverterFactory.class, TestKeyValueFilterConverterFactory.class);
    }

    private Configuration createRemoteCacheManagerConfiguration(int... hotrodPortOverrides) {
        if (hotrodPortOverrides.length != 0) {
            assert getServers().size() == hotrodPortOverrides.length : "The number of defined ports is different from server count";
        }
        ConfigurationBuilder config = new ConfigurationBuilder();
        int index = 0;
        for (RemoteInfinispanServer server : getServers()) {
            int port = hotrodPortOverrides.length != 0 ? hotrodPortOverrides[index] : server.getHotrodEndpoint().getPort();
            config.addServer()
                    .host(server.getHotrodEndpoint().getInetAddress().getHostName())
                    .port(port);
            ++index;
        }
        config.balancingStrategy("org.infinispan.server.test.client.hotrod.HotRodTestRequestBalancingStrategy")
                // load balancing
                .balancingStrategy("org.infinispan.client.hotrod.impl.transport.tcp.RoundRobinBalancingStrategy")
                .forceReturnValues(false)
                        // TCP stuff
                .tcpNoDelay(true)
                .transportFactory("org.infinispan.client.hotrod.impl.transport.tcp.TcpTransportFactory")
                        // marshalling
                        // FIXME Workaround for ISPN-6367
                .marshaller(new GenericJBossMarshaller(Thread.currentThread().getContextClassLoader()))
                        // executors
                .asyncExecutorFactory().factoryClass("org.infinispan.client.hotrod.impl.async.DefaultAsyncExecutorFactory")
                .addExecutorProperty("infinispan.client.hotrod.default_executor_factory.pool_size", "10")
                .addExecutorProperty("infinispan.client.hotrod.default_executor_factory.queue_size", "100000")
                        //hashing
                .keySizeEstimate(64)
                .valueSizeEstimate(512);
        return config.build();
    }

    private long numEntriesOnServer(int serverIndex) {
        return getServers().get(serverIndex).
                getCacheManager(isLocalMode() ? "local" : "clustered").
                getCache(testCache).getNumberOfEntries();
    }

    @Test
    public void testReplaceWithVersionWithLifespan() throws Exception {
        int lifespanInSecs = 1;
        assertNull(remoteCache.replace("aKey", "aValue"));
        remoteCache.put("aKey", "aValue");
        VersionedValue valueBinary = remoteCache.getVersioned("aKey");
        assertTrue(remoteCache.replaceWithVersion("aKey", "aNewValue", valueBinary.getVersion(), lifespanInSecs));

        // version should have changed; value should have changed
        VersionedValue entry2 = remoteCache.getVersioned("aKey");
        assertNotEquals(entry2.getVersion(), valueBinary.getVersion());
        assertEquals("aNewValue", entry2.getValue());

        sleepForSecs(lifespanInSecs + 1);
        assertNull(remoteCache.getVersioned("aKey"));
    }

    @Test
    public void testPut() throws Exception {
        assertNull(remoteCache.put("aKey", "aValue"));
        assertTrue(remoteCache.containsKey("aKey"));
        assertEquals(remoteCache.get("aKey"), "aValue");
    }

    @Test
    public void testPutWithLifespan() {
        long lifespanInSecs = 1;
        remoteCache.put("lkey", "value", lifespanInSecs, TimeUnit.SECONDS);
        sleepForSecs(lifespanInSecs + 1);
        assertNull(remoteCache.get("lkey"));
    }

    @Test
    public void testSize() {
        assertEquals(0, remoteCache.size());

        // with force_return_value=false as default, this opertion
        // should return null even if a previous value for aPut was there
        assertNull(remoteCache.put("aKey", "aValue"));
        assertTrue(remoteCache.containsKey("aKey"));
        assertEquals(remoteCache.size(), 1);

        // should be idempotent
        assertEquals(remoteCache.size(), 1);

        assertNull(remoteCache.put("anotherKey", "anotherValue"));
        assertTrue(remoteCache.containsKey("anotherKey"));
        assertEquals(remoteCache.size(), 2);

        assertNull(remoteCache.remove("anotherKey"));
        assertTrue(!remoteCache.containsKey("anotherKey"));
        assertEquals(remoteCache.size(), 1);

        assertNull(remoteCache.remove("aKey"));
        assertTrue(!remoteCache.containsKey("aKey"));
        assertEquals(remoteCache.size(), 0);
    }

    @Test
    public void testIsEmpty() throws IOException {

        assertTrue(remoteCache.isEmpty());

        assertNull(remoteCache.put("aKey", "aValue"));
        assertTrue(remoteCache.containsKey("aKey"));
        assertTrue(!remoteCache.isEmpty());

        assertNull(remoteCache.remove("aKey"));
        assertTrue(!remoteCache.containsKey("aKey"));
        assertTrue(remoteCache.isEmpty());
    }

    @Test
    public void testContains() {
        assertTrue(!remoteCache.containsKey("aKey"));
        remoteCache.put("aKey", "aValue");
        assertTrue(remoteCache.containsKey("aKey"));
    }

    @Test
    public void testWithFlags() throws IOException {

        assertNull(remoteCache.put("aKey", "aValue"));
        assertTrue(remoteCache.containsKey("aKey"));
        assertEquals("aValue", remoteCache.get("aKey"));

        // should not return return old value
        assertNull(remoteCache.put("aKey", "anotherValue"));
        assertEquals("anotherValue", remoteCache.get("aKey"));

        // now should return old value
        assertEquals("anotherValue", remoteCache.withFlags(Flag.FORCE_RETURN_VALUE).put("aKey", "yetAnotherValue"));
    }

    @Test
    public void testBulkOperations() {

        Map<String, String> mapIn;
        Map<String, String> mapOut = new HashMap<String, String>();
        mapOut.put("aKey", "aValue");
        mapOut.put("bKey", "bValue");
        mapOut.put("cKey", "cValue");

        remoteCache.putAll(mapOut);
        mapIn = remoteCache.getBulk();

        // check that the maps are equal
        assertEquals(mapIn, mapOut);
    }

    @Test
    public void testBulkOperationsWithLifespan() {

        long lifespanInSecs = 1;

        Map<String, String> mapIn = new HashMap<String, String>();
        Map<String, String> mapOut = new HashMap<String, String>();
        mapOut.put("aKey", "aValue");
        mapOut.put("bKey", "bValue");
        mapOut.put("cKey", "cValue");

        remoteCache.putAll(mapOut, lifespanInSecs, TimeUnit.SECONDS);

        // give the elements time to be evicted
        sleepForSecs(lifespanInSecs + 1);

        mapIn = remoteCache.getBulk();
        assertEquals(mapIn.size(), 0);
    }

    @Test
    public void testGetBulkWithLimit() {
        Map<String, String> mapIn;
        Map<String, String> mapOut = new HashMap<String, String>();
        mapOut.put("aKey", "aValue");
        mapOut.put("bKey", "bValue");
        mapOut.put("cKey", "cValue");

        remoteCache.putAll(mapOut);
        mapIn = remoteCache.getBulk(2);
        // we don't know which 2 entries will be retrieved
        assertEquals(mapIn.size(), 2);
    }

    @Test
    public void testGetName() {
        // in hotrod protocol specification, the default cache is identified by an empty string
        assertEquals(testCache, remoteCache.getName());
    }

    @Test
    public void testKeySet() {
        remoteCache.put("k1", "v1");
        remoteCache.put("k2", "v2");
        remoteCache.put("k3", "v3");

        Set<String> expectedKeySet = new HashSet<String>();
        expectedKeySet.add("k1");
        expectedKeySet.add("k2");
        expectedKeySet.add("k3");
        assertEquals(expectedKeySet, remoteCache.keySet());
    }

    @Test
    public void testGetWithMetadata() {
        remoteCache.put("k1", "v1", 10000000, TimeUnit.MICROSECONDS); // setting only lifespan
        remoteCache.put("k2", "v2", 10, TimeUnit.SECONDS, 10, TimeUnit.SECONDS); // lifespan + maxIdleTime
        MetadataValue<String> k1 = remoteCache.getWithMetadata("k1");
        MetadataValue<String> k2 = remoteCache.getWithMetadata("k2");
        assertEquals(k1.getValue(), "v1");
        // microseconds converted to seconds
        assertEquals(k1.getLifespan(), 10);
        assertEquals(k1.getMaxIdle(), -1);
        assertEquals(k2.getValue(), "v2");
        assertEquals(k2.getLifespan(), 10);
        assertEquals(k2.getMaxIdle(), 10);
    }

    @Test
    public void testRemoveAsync() throws Exception {
        for (int i = 0; i <= ASYNC_OPS_ENTRY_LOAD; i++) {
            remoteCache.put("key" + i, "value" + i);
        }
        Set<Future<?>> futures = new HashSet<Future<?>>();
        for (int i = 0; i <= ASYNC_OPS_ENTRY_LOAD; i++) {
            futures.add(remoteCache.removeAsync("key" + i));
        }
        for (Future<?> f : futures) {
            f.get();
        }
        assertEquals(0, numEntriesOnServer(0));
    }

    @Test
    public void testReplaceAsync() throws Exception {
        remoteCache.clear();
        for (int i = 0; i <= ASYNC_OPS_ENTRY_LOAD; i++) {
            remoteCache.put("key" + i, "value" + i);
        }
        Set<Future<?>> futures = new HashSet<Future<?>>();
        for (int i = 0; i <= ASYNC_OPS_ENTRY_LOAD; i++) {
            futures.add(remoteCache.replaceAsync("key" + i, "newValue" + i, -1, TimeUnit.SECONDS, -1, TimeUnit.SECONDS));
        }
        for (Future<?> f : futures) {
            f.get();
        }
        for (int i = 0; i <= ASYNC_OPS_ENTRY_LOAD; i++) {
            assertEquals("newValue" + i, remoteCache.get("key" + i));
        }
    }

    @Test
    public void testGetVersionedCacheEntry() {

        VersionedValue value = remoteCache.getVersioned("aKey");
        assertNull("expected null but received: " + value, remoteCache.getVersioned("aKey"));

        remoteCache.put("aKey", "aValue");
        assertEquals("aValue", remoteCache.get("aKey"));
        VersionedValue valueBinary = remoteCache.getVersioned("aKey");
        assertNotNull(valueBinary);
        assertEquals(valueBinary.getValue(), "aValue");

        // now put the same value
        remoteCache.put("aKey", "aValue");
        VersionedValue entry2 = remoteCache.getVersioned("aKey");
        assertEquals(entry2.getValue(), "aValue");

        assertNotEquals(entry2.getVersion(), valueBinary.getVersion());
        assertNotEquals(valueBinary, entry2);

        // now put a different value
        remoteCache.put("aKey", "anotherValue");
        VersionedValue entry3 = remoteCache.getVersioned("aKey");
        assertEquals(entry3.getValue(), "anotherValue");
        assertNotEquals(entry3.getVersion(), entry2.getVersion());
        assertNotEquals(entry3, entry2);
    }

    @Test
    public void testReplace() {
        // this should return null, indicating no k/v pair in the map
        assertNull(remoteCache.replace("aKey", "anotherValue"));
        remoteCache.put("aKey", "aValue");
        assertNull(remoteCache.replace("aKey", "anotherValue"));
        assertEquals(remoteCache.get("aKey"), "anotherValue");
    }

    @Test
    public void testReplaceWithVersion() {
        assertNull(remoteCache.replace("aKey", "aValue"));

        remoteCache.put("aKey", "aValue");
        VersionedValue valueBinary = remoteCache.getVersioned("aKey");
        // replacement should take place (and so return true)
        assertTrue(remoteCache.replaceWithVersion("aKey", "aNewValue", valueBinary.getVersion()));

        // version should have changed; value should have changed
        VersionedValue entry2 = remoteCache.getVersioned("aKey");
        assertNotEquals(entry2.getVersion(), valueBinary.getVersion());
        assertEquals(entry2.getValue(), "aNewValue");

        // replacement should not take place because we have changed the value
        assertTrue(!remoteCache.replaceWithVersion("aKey", "aNewValue", valueBinary.getVersion()));
    }

    @Test
    public void testRemove() throws IOException {
        assertNull(remoteCache.put("aKey", "aValue"));
        assertEquals(remoteCache.get("aKey"), "aValue");

        assertNull(remoteCache.remove("aKey"));
        assertTrue(!remoteCache.containsKey("aKey"));
    }

    @Test
    public void testRemoveWithVersion() {

        assertTrue(!remoteCache.removeWithVersion("aKey", 12321212l));

        remoteCache.put("aKey", "aValue");
        VersionedValue valueBinary = remoteCache.getVersioned("aKey");
        assertTrue(remoteCache.removeWithVersion("aKey", valueBinary.getVersion()));

        remoteCache.put("aKey", "aNewValue");

        VersionedValue entry2 = remoteCache.getVersioned("aKey");
        assertNotEquals(entry2.getVersion(), valueBinary.getVersion());
        assertEquals(entry2.getValue(), "aNewValue");

        assertTrue(!remoteCache.removeWithVersion("aKey", valueBinary.getVersion()));
    }

    @Test
    public void testPutIfAbsent() {

        remoteCache.putIfAbsent("aKey", "aValue");
        assertEquals(remoteCache.size(), 1);
        assertEquals(remoteCache.get("aKey"), "aValue");

        assertNull(remoteCache.putIfAbsent("aKey", "anotherValue"));
        assertEquals(remoteCache.get("aKey"), "aValue");
    }

    @Test
    public void testPutIfAbsentWithLifespan() throws Exception {
        int lifespanInSecs = 1;
        remoteCache.putIfAbsent("aKey", "aValue", lifespanInSecs, TimeUnit.SECONDS, -1, TimeUnit.SECONDS);
        assertEquals(1, remoteCache.size());
        assertEquals("aValue", remoteCache.get("aKey"));

        sleepForSecs(lifespanInSecs + 1);
        //verify the entry expired
        assertEquals(null, remoteCache.get("akey"));

        remoteCache.putIfAbsent("aKey", "aValue");
        assertEquals(1, remoteCache.size());
        assertEquals("aValue", remoteCache.get("aKey"));
        assertEquals(null, remoteCache.putIfAbsent("aKey", "anotherValue", lifespanInSecs, TimeUnit.SECONDS, -1, TimeUnit.SECONDS));

        sleepForSecs(lifespanInSecs + 1);
        //verify the entry is still there because it was not put with lifespan since it had already existed
        assertEquals("aValue", remoteCache.get("aKey"));
    }

    @Test
    public void testClear() {
        remoteCache.put("aKey", "aValue");
        remoteCache.put("aKey2", "aValue");
        remoteCache.clear();
        assertTrue(!remoteCache.containsKey("aKey"));
        assertTrue(!remoteCache.containsKey("aKey2"));
    }

    @Test
    public void testGetRemoteCacheManager() {

        RemoteCacheManager manager = null;

        manager = remoteCache.getRemoteCacheManager();
        assertEquals("getRemoteCachemanager() returned incorrect value", manager, remoteCacheManager);
    }

    @Test
    public void testStats() {
        ServerStatistics remoteStats = remoteCache.stats();
        assertNotNull(remoteStats);
        log.tracef("named stats = %s", remoteStats.getStatsMap());
    }

    @Test
    public void testUnsupportedOperations() {

        try {
            remoteCache.remove("aKey", "aValue");
            fail("call to remove() did not raise UnsupportedOperationException ");
        } catch (UnsupportedOperationException uoe) {
            // Unsupported operation exception correctly thrown
        }
        try {
            remoteCache.removeAsync("aKey", "aValue");
            fail("call to removeAsync() did not raise UnsupportedOperationException ");
        } catch (UnsupportedOperationException uoe) {
            // Unsupported operation exception correctly thrown
        }

        try {
            remoteCache.replace("aKey", "oldValue", "newValue");
            fail("call to replace() did not raise UnsupportedOperationException ");
        } catch (UnsupportedOperationException uoe) {
            // Unsupported operation exception correctly thrown
        }
        try {
            remoteCache.replace("aKey", "oldValue", "newValue", -1, TimeUnit.SECONDS);
            fail("call to replace() did not raise UnsupportedOperationException ");
        } catch (UnsupportedOperationException uoe) {
            // Unsupported operation exception correctly thrown
        }
        try {
            remoteCache.replace("aKey", "oldValue", "newValue", -1, TimeUnit.SECONDS, -1, TimeUnit.SECONDS);
            fail("call to replace() did not raise UnsupportedOperationException ");
        } catch (UnsupportedOperationException uoe) {
            // Unsupported operation exception correctly thrown
        }
        try {
            remoteCache.replaceAsync("aKey", "oldValue", "newValue");
            fail("call to replaceAsync() did not raise UnsupportedOperationException ");
        } catch (UnsupportedOperationException uoe) {
            // Unsupported operation exception correctly thrown
        }
        try {
            remoteCache.replaceAsync("aKey", "oldValue", "newValue", -1, TimeUnit.SECONDS);
            fail("call to replaceAsync() did not raise UnsupportedOperationException ");
        } catch (UnsupportedOperationException uoe) {
            // Unsupported operation exception correctly thrown
        }
        try {
            remoteCache.replaceAsync("aKey", "oldValue", "newValue", -1, TimeUnit.SECONDS, -1, TimeUnit.SECONDS);
            fail("call to replaceAsync() did not raise UnsupportedOperationException ");
        } catch (UnsupportedOperationException uoe) {
            // Unsupported operation exception correctly thrown
        }
        try {
            remoteCache.containsValue("aValue");
            fail("call to containsValue() did not raise UnsupportedOperationException ");
        } catch (UnsupportedOperationException uoe) {
            // Unsupported operation exception correctly thrown
        }
        try {
            remoteCache.entrySet();
            fail("call to entrySet() did not raise UnsupportedOperationException ");
        } catch (UnsupportedOperationException uoe) {
            // Unsupported operation exception correctly thrown
        }
        try {
            remoteCache.values();
            fail("call to values() did not raise UnsupportedOperationException ");
        } catch (UnsupportedOperationException uoe) {
            // Unsupported operation exception correctly thrown
        }
    }

    @Test
    public void testClearAsync() throws Exception {
        fill(remoteCache, ASYNC_OPS_ENTRY_LOAD);
        assertEquals(ASYNC_OPS_ENTRY_LOAD, numEntriesOnServer(0));

        CompletableFuture<Void> future = remoteCache.clearAsync();
        future.get();

        assertEquals(0, numEntriesOnServer(0));
    }

    @Test
    public void testPutAsync() throws Exception {
        assertNull(remoteCache.get("k"));
        Future<String> f = remoteCache.putAsync("k", "v");
        assertFalse(f.isCancelled());
        assertNull(f.get());
        assertTrue(f.isDone());
        assertEquals("v", remoteCache.get("k"));
    }

    @Test
    public void testPutWithLifespanAsync() throws Exception {
        long lifespanInSecs = 2;
        Set<Future<?>> futures = new HashSet<Future<?>>();
        for (int i = 0; i != ASYNC_OPS_ENTRY_LOAD; i++) {
            futures.add(remoteCache.putAsync("key" + i, "value" + i, lifespanInSecs, TimeUnit.SECONDS, -1, TimeUnit.SECONDS));
        }
        for (Future<?> f : futures) {
            f.get();
        }
        assertEquals(ASYNC_OPS_ENTRY_LOAD, numEntriesOnServer(0));
        sleepForSecs(lifespanInSecs + 1);
        for (int i = 0; i != ASYNC_OPS_ENTRY_LOAD; i++) {
            assertEquals(null, remoteCache.get("key" + i));
        }
    }

    @Test
    public void testPutIfAbsentAsync() throws Exception {
        Set<Future<?>> futures = new HashSet<Future<?>>();
        for (int i = 0; i != ASYNC_OPS_ENTRY_LOAD; i++) {
            futures.add(remoteCache.putIfAbsentAsync("key" + i, "value" + i));
        }
        // check that the puts completed successfully
        for (Future<?> f : futures) {
            f.get();
        }
        assertEquals(ASYNC_OPS_ENTRY_LOAD, numEntriesOnServer(0));
        assertEquals("value" + (ASYNC_OPS_ENTRY_LOAD - 1), remoteCache.get("key" + (ASYNC_OPS_ENTRY_LOAD - 1)));
        for (int i = 0; i != ASYNC_OPS_ENTRY_LOAD; i++) {
            futures.add(remoteCache.putIfAbsentAsync("key" + i, "newValue" + i));
        }
        for (Future<?> f : futures) {
            f.get();
        }
        for (int i = 0; i != ASYNC_OPS_ENTRY_LOAD; i++) {
            assertEquals("value" + i, remoteCache.get("key" + i));
        }
    }

    @Test
    public void testPutIfAbsentWithLifespanAsync() throws Exception {
        long lifespanInSecs = 2;
        Set<Future<?>> futures = new HashSet<Future<?>>();
        for (int i = 0; i != ASYNC_OPS_ENTRY_LOAD; i++) {
            futures.add(remoteCache.putIfAbsentAsync("key" + i, "value" + i, lifespanInSecs, TimeUnit.SECONDS));
        }
        for (Future<?> f : futures) {
            f.get();
        }
        assertEquals(ASYNC_OPS_ENTRY_LOAD, numEntriesOnServer(0));
        assertEquals("value" + (ASYNC_OPS_ENTRY_LOAD - 1), remoteCache.get("key" + (ASYNC_OPS_ENTRY_LOAD - 1)));
        sleepForSecs(lifespanInSecs + 1);
        for (int i = 0; i != ASYNC_OPS_ENTRY_LOAD; i++) {
            assertEquals(null, remoteCache.get("key" + i));
        }
    }

    @Test
    public void testReplaceWithVersionWithLifespanAsync() throws Exception {
        int lifespanInSecs = 2;
        assertNull(remoteCache.replace("aKey", "aValue"));

        remoteCache.put("aKey", "aValue");
        VersionedValue valueBinary = remoteCache.getVersioned("aKey");
        CompletableFuture<Boolean> future = remoteCache.replaceWithVersionAsync("aKey", "aNewValue", valueBinary.getVersion(),
                lifespanInSecs);
        assertTrue(future.get());

        // version should have changed; value should have changed
        VersionedValue entry2 = remoteCache.getVersioned("aKey");
        assertNotEquals(entry2.getVersion(), valueBinary.getVersion());
        assertEquals("aNewValue", entry2.getValue());

        sleepForSecs(lifespanInSecs + 1);
        assertNull(remoteCache.getVersioned("aKey"));
    }

    @Test
    public void testGetAsync() throws Exception {
        fill(remoteCache, ASYNC_OPS_ENTRY_LOAD);
        assertEquals(ASYNC_OPS_ENTRY_LOAD, numEntriesOnServer(0));

        Set<Future<?>> futures = new HashSet<Future<?>>();
        for (int i = 0; i != ASYNC_OPS_ENTRY_LOAD; i++) {
            futures.add(remoteCache.getAsync("key" + i));
        }
        for (Future<?> f : futures) {
            assertNotNull(f.get());
        }
    }

    @Test
    public void testBulkOperationsAsync() throws Exception {
        Map<String, String> mapIn = new HashMap<String, String>();
        Map<String, String> mapOut = new HashMap<String, String>();
        fill(mapOut, ASYNC_OPS_ENTRY_LOAD);
        CompletableFuture<Void> future = remoteCache.putAllAsync(mapOut);
        future.get();

        mapIn = remoteCache.getBulk();
        assertEquals(mapOut, mapIn);
    }

    @Test
    public void testBulkOperationsWithLifespanAsync() throws Exception {
        long lifespanInSecs = 3;
        Map<String, String> mapIn = new HashMap<String, String>();
        Map<String, String> mapOut = new HashMap<String, String>();
        fill(mapOut, ASYNC_OPS_ENTRY_LOAD);
        CompletableFuture<Void> future = remoteCache.putAllAsync(mapOut, lifespanInSecs, TimeUnit.SECONDS);
        future.get();

        sleepForSecs(lifespanInSecs + 2);
        mapIn = remoteCache.getBulk();
        assertEquals(0, mapIn.size());
    }

    @Test
    public void testReplaceWithVersionAsync() throws Exception {
        assertNull(remoteCache.replace("aKey", "aValue"));

        remoteCache.put("aKey", "aValue");
        VersionedValue valueBinary = remoteCache.getVersioned("aKey");
        // replacement should take place (and so return true)
        CompletableFuture<Boolean> future = remoteCache.replaceWithVersionAsync("aKey", "aNewValue", valueBinary.getVersion());
        assertTrue(future.get());

        // version should have changed; value should have changed
        VersionedValue entry2 = remoteCache.getVersioned("aKey");
        assertNotEquals(entry2.getVersion(), valueBinary.getVersion());
        assertEquals("aNewValue", entry2.getValue());

        // replacement should not take place because we have changed the value
        future = remoteCache.replaceWithVersionAsync("aKey", "aNewValue", valueBinary.getVersion());
        assertFalse(future.get());
    }

    @Test
    public void testRemoveWithVersionAsync() throws Exception {
        CompletableFuture<Boolean> future = null;
        future = remoteCache.removeWithVersionAsync("aKey", 12321212l);
        assertTrue(!future.get());

        remoteCache.put("aKey", "aValue");
        VersionedValue valueBinary = remoteCache.getVersioned("aKey");
        future = remoteCache.removeWithVersionAsync("aKey", valueBinary.getVersion());
        assertTrue(future.get());

        remoteCache.put("aKey", "aNewValue");
        VersionedValue entry2 = remoteCache.getVersioned("aKey");
        assertNotEquals(entry2.getVersion(), valueBinary.getVersion());
        assertEquals(entry2.getValue(), "aNewValue");

        future = remoteCache.removeWithVersionAsync("aKey", valueBinary.getVersion());
        assertTrue(!future.get());
    }

    @Test
    public void testGetProtocolVersion() throws Exception {
        assertEquals("HotRod client, protocol version: 2.5", remoteCache.getProtocolVersion());
    }

    @Test
    public void testPutGetCustomObject() throws Exception {
        final Person p = new Person("Martin");
        remoteCache.put("k1", p);
        assertEquals(p, remoteCache.get("k1"));
    }

    @Test
    public void testEventReceiveBasic() {
        final EventLogListener eventListener = new EventLogListener();
        remoteCache.addClientListener(eventListener);
        try {
            expectNoEvents(eventListener);
            // Created events
            remoteCache.put(1, "one");
            expectOnlyCreatedEvent(1, eventListener);
            remoteCache.put(2, "two");
            expectOnlyCreatedEvent(2, eventListener);
            // Modified events
            remoteCache.put(1, "newone");
            expectOnlyModifiedEvent(1, eventListener);
            // Remove events
            remoteCache.remove(1);
            expectOnlyRemovedEvent(1, eventListener);
        } finally {
            remoteCache.removeClientListener(eventListener);
        }
    }

    @Test
    public void testEventReceiveConditional() {
        final EventLogListener eventListener = new EventLogListener();
        remoteCache.addClientListener(eventListener);
        try {
            expectNoEvents(eventListener);
            // Put if absent
            remoteCache.putIfAbsent(1, "one");
            expectOnlyCreatedEvent(1, eventListener);
            remoteCache.putIfAbsent(1, "again");
            expectNoEvents(eventListener);
            // Replace
            remoteCache.replace(1, "newone");
            expectOnlyModifiedEvent(1, eventListener);
            // Replace with version
            remoteCache.replaceWithVersion(1, "one", 0);
            expectNoEvents(eventListener);
            VersionedValue<String> versioned = remoteCache.getVersioned(1);
            remoteCache.replaceWithVersion(1, "one", versioned.getVersion());
            expectOnlyModifiedEvent(1, eventListener);
            // Remove with version
            remoteCache.removeWithVersion(1, 0);
            expectNoEvents(eventListener);
            versioned = remoteCache.getVersioned(1);
            remoteCache.removeWithVersion(1, versioned.getVersion());
            expectOnlyRemovedEvent(1, eventListener);
        } finally {
            remoteCache.removeClientListener(eventListener);
        }
    }

    @Test
    public void testEventFilteringStatic() {
        final StaticFilteredEventLogListener eventListener = new StaticFilteredEventLogListener();
        remoteCache.addClientListener(eventListener);
        try {
            expectNoEvents(eventListener);
            remoteCache.put(1, "one");
            expectNoEvents(eventListener);
            remoteCache.put(2, "two");
            expectOnlyCreatedEvent(2, eventListener);
            remoteCache.remove(1);
            expectNoEvents(eventListener);
            remoteCache.remove(2);
            expectOnlyRemovedEvent(2, eventListener);
        } finally {
            remoteCache.removeClientListener(eventListener);
        }
    }

    @Test
    public void testEventFilteringDynamic() {
        final DynamicFilteredEventLogListener eventListener = new DynamicFilteredEventLogListener();
        remoteCache.addClientListener(eventListener, new Object[]{3}, null);
        try {
            expectNoEvents(eventListener);
            remoteCache.put(1, "one");
            expectNoEvents(eventListener);
            remoteCache.put(2, "two");
            expectNoEvents(eventListener);
            remoteCache.put(3, "three");
            expectOnlyCreatedEvent(3, eventListener);
            remoteCache.replace(1, "new-one");
            expectNoEvents(eventListener);
            remoteCache.replace(2, "new-two");
            expectNoEvents(eventListener);
            remoteCache.replace(3, "new-three");
            expectOnlyModifiedEvent(3, eventListener);
            remoteCache.remove(1);
            expectNoEvents(eventListener);
            remoteCache.remove(2);
            expectNoEvents(eventListener);
            remoteCache.remove(3);
            expectOnlyRemovedEvent(3, eventListener);
        } finally {
            remoteCache.removeClientListener(eventListener);
        }
    }

    @Test
    public void testCustomEvents() {
        final StaticCustomEventLogListener eventListener = new StaticCustomEventLogListener();
        remoteCache.addClientListener(eventListener);
        try {
            eventListener.expectNoEvents();
            remoteCache.put(1, "one");
            eventListener.expectSingleCustomEvent(1, "one");
            remoteCache.put(1, "newone");
            eventListener.expectSingleCustomEvent(1, "newone");
            remoteCache.remove(1);
            eventListener.expectSingleCustomEvent(1, null);
        } finally {
            remoteCache.removeClientListener(eventListener);
        }
    }

    @Test
    public void testCustomEventsDynamic() {
        final DynamicCustomEventLogListener eventListener = new DynamicCustomEventLogListener();
        remoteCache.addClientListener(eventListener, null, new Object[]{2});
        try {
            eventListener.expectNoEvents();
            remoteCache.put(1, "one");
            eventListener.expectSingleCustomEvent(1, "one");
            remoteCache.put(2, "two");
            eventListener.expectSingleCustomEvent(2, null);
        } finally {
            remoteCache.removeClientListener(eventListener);
        }
    }

    @Test
    public void testCustomFilterEvents() {
        final FilterCustomEventLogListener eventListener = new FilterCustomEventLogListener();
        remoteCache.addClientListener(eventListener, new Object[]{3}, null);
        try {
            eventListener.expectNoEvents();
            remoteCache.put(1, "one");
            eventListener.expectSingleCustomEvent(1, "one");
            remoteCache.put(1, "uno");
            eventListener.expectSingleCustomEvent(1, "uno");
            remoteCache.put(2, "two");
            eventListener.expectSingleCustomEvent(2, "two");
            remoteCache.put(2, "dos");
            eventListener.expectSingleCustomEvent(2, "dos");
            remoteCache.put(3, "three");
            eventListener.expectSingleCustomEvent(3, null);
            remoteCache.put(3, "tres");
            eventListener.expectSingleCustomEvent(3, null);
            remoteCache.remove(1);
            eventListener.expectSingleCustomEvent(1, null);
            remoteCache.remove(2);
            eventListener.expectSingleCustomEvent(2, null);
            remoteCache.remove(3);
            eventListener.expectSingleCustomEvent(3, null);
        } finally {
            remoteCache.removeClientListener(eventListener);
        }
    }

   @Test
    public void testIterationWithCustomClasses() {
       remoteCache.put("1", new SampleEntity("value1,value2"));
       remoteCache.put("2", new SampleEntity("value3,value2"));
       remoteCache.put("ignore", new SampleEntity("whatever"));
       remoteCache.put("3", new SampleEntity("value7,value8"));

       final Map<Object, Object> entryMap = new HashMap<>();
       try(CloseableIterator<Entry<Object, Object>> closeableIterator = remoteCache.retrieveEntries("csv-key-value-filter-converter-factory", null, 10)) {
          closeableIterator.forEachRemaining(new Consumer<Entry<Object, Object>>() {
             @Override
             public void accept(Entry<Object, Object> e) {
                entryMap.put(e.getKey(), e.getValue());
             }
          });
       }

      assertEquals(3, entryMap.size());
      assertEquals(Arrays.asList("value1","value2"), ((Summary) entryMap.get("1")).getAttributes());
      assertEquals(Arrays.asList("value3","value2"), ((Summary) entryMap.get("2")).getAttributes());
      assertEquals(Arrays.asList("value7","value8"), ((Summary) entryMap.get("3")).getAttributes());
    }

    @Test
    public void testEventFilteringCustomPojo() {
        final CustomPojoFilteredEventLogListener eventListener = new CustomPojoFilteredEventLogListener();
        remoteCache.addClientListener(eventListener, new Object[]{"two"}, null);
        try {
            expectNoEvents(eventListener);
            remoteCache.put(1, new Person("one"));
            expectNoEvents(eventListener);
            remoteCache.put(2, new Person("two"));
            expectOnlyCreatedEvent(2, eventListener);
            remoteCache.remove(1);
            expectNoEvents(eventListener);
            remoteCache.remove(2);
            expectOnlyRemovedEvent(2, eventListener);
        } finally {
            remoteCache.removeClientListener(eventListener);
        }
    }

    @Test
    public void testCustomEventsCustomPojo() {
        final CustomPojoCustomEventLogListener eventListener = new CustomPojoCustomEventLogListener();
        remoteCache.addClientListener(eventListener, null, new Object[]{new Person("two")});
        try {
            eventListener.expectNoEvents();
            remoteCache.put(1, new Person("one"));
            eventListener.expectSingleCustomEvent(1, new Person("one"));
            remoteCache.put(2, new Person("two"));
            eventListener.expectSingleCustomEvent(2, null);
        } finally {
            remoteCache.removeClientListener(eventListener);
        }
    }

    @Test
    public void testCustomFilterEventsCustomPojo() {
        final CustomPojoFilterCustomEventLogListener eventListener = new CustomPojoFilterCustomEventLogListener();
        remoteCache.addClientListener(eventListener, new Object[]{new Id(3)}, null);
        try {
            eventListener.expectNoEvents();
            remoteCache.put(new Id(1), new Person("one"));
            eventListener.expectSingleCustomEvent(new Id(1), new Person("one"));
            remoteCache.put(new Id(1), new Person("uno"));
            eventListener.expectSingleCustomEvent(new Id(1), new Person("uno"));
            remoteCache.put(new Id(2), new Person("two"));
            eventListener.expectSingleCustomEvent(new Id(2), new Person("two"));
            remoteCache.put(new Id(2), new Person("dos"));
            eventListener.expectSingleCustomEvent(new Id(2), new Person("dos"));
            remoteCache.put(new Id(3), new Person("three"));
            eventListener.expectSingleCustomEvent(new Id(3), null);
            remoteCache.put(new Id(3), new Person("tres"));
            eventListener.expectSingleCustomEvent(new Id(3), null);
            remoteCache.remove(new Id(1));
            eventListener.expectSingleCustomEvent(new Id(1), null);
            remoteCache.remove(new Id(2));
            eventListener.expectSingleCustomEvent(new Id(2), null);
            remoteCache.remove(new Id(3));
            eventListener.expectSingleCustomEvent(new Id(3), null);
        } finally {
            remoteCache.removeClientListener(eventListener);
        }
    }

    public static <K> void expectOnlyCreatedEvent(K key, EventLogListener eventListener) {
        expectSingleEvent(key, eventListener, ClientEvent.Type.CLIENT_CACHE_ENTRY_CREATED);
        expectNoEvents(eventListener, ClientEvent.Type.CLIENT_CACHE_ENTRY_MODIFIED);
        expectNoEvents(eventListener, ClientEvent.Type.CLIENT_CACHE_ENTRY_REMOVED);
    }

    public static <K> void expectOnlyModifiedEvent(K key, EventLogListener eventListener) {
        expectSingleEvent(key, eventListener, ClientEvent.Type.CLIENT_CACHE_ENTRY_MODIFIED);
        expectNoEvents(eventListener, ClientEvent.Type.CLIENT_CACHE_ENTRY_CREATED);
        expectNoEvents(eventListener, ClientEvent.Type.CLIENT_CACHE_ENTRY_REMOVED);
    }

    public static <K> void expectOnlyRemovedEvent(K key, EventLogListener eventListener) {
        expectSingleEvent(key, eventListener, ClientEvent.Type.CLIENT_CACHE_ENTRY_REMOVED);
        expectNoEvents(eventListener, ClientEvent.Type.CLIENT_CACHE_ENTRY_CREATED);
        expectNoEvents(eventListener, ClientEvent.Type.CLIENT_CACHE_ENTRY_MODIFIED);
    }

    public static <K> void expectSingleEvent(K key, EventLogListener eventListener, ClientEvent.Type type) {
        switch (type) {
            case CLIENT_CACHE_ENTRY_CREATED:
                ClientCacheEntryCreatedEvent createdEvent = eventListener.pollEvent(type);
                assertEquals(key, createdEvent.getKey());
                break;
            case CLIENT_CACHE_ENTRY_MODIFIED:
                ClientCacheEntryModifiedEvent modifiedEvent = eventListener.pollEvent(type);
                assertEquals(key, modifiedEvent.getKey());
                break;
            case CLIENT_CACHE_ENTRY_REMOVED:
                ClientCacheEntryRemovedEvent removedEvent = eventListener.pollEvent(type);
                assertEquals(key, removedEvent.getKey());
                break;
        }
        assertEquals(0, eventListener.queue(type).size());
    }

    public static void expectNoEvents(EventLogListener eventListener) {
        expectNoEvents(eventListener, ClientEvent.Type.CLIENT_CACHE_ENTRY_CREATED);
        expectNoEvents(eventListener, ClientEvent.Type.CLIENT_CACHE_ENTRY_MODIFIED);
        expectNoEvents(eventListener, ClientEvent.Type.CLIENT_CACHE_ENTRY_REMOVED);
    }

    public static void expectNoEvents(EventLogListener eventListener, ClientEvent.Type type) {
        switch (type) {
            case CLIENT_CACHE_ENTRY_CREATED:
                assertEquals(0, eventListener.createdEvents.size());
                break;
            case CLIENT_CACHE_ENTRY_MODIFIED:
                assertEquals(0, eventListener.modifiedEvents.size());
                break;
            case CLIENT_CACHE_ENTRY_REMOVED:
                assertEquals(0, eventListener.removedEvents.size());
                break;
        }
    }

    @ClientListener(filterFactoryName = "static-filter-factory")
    public static class StaticFilteredEventLogListener extends EventLogListener {}

    @ClientListener(filterFactoryName = "dynamic-filter-factory")
    public static class DynamicFilteredEventLogListener extends EventLogListener {}

    @ClientListener(converterFactoryName = "static-converter-factory")
    public static class StaticCustomEventLogListener extends CustomEventLogListener {}

    @ClientListener(converterFactoryName = "dynamic-converter-factory")
    public static class DynamicCustomEventLogListener extends CustomEventLogListener {}

    @ClientListener(filterFactoryName = "filter-converter-factory", converterFactoryName = "filter-converter-factory")
    public static class FilterCustomEventLogListener extends CustomEventLogListener {}

    @ClientListener(filterFactoryName = "pojo-filter-factory")
    public static class CustomPojoFilteredEventLogListener extends EventLogListener {}

    @ClientListener(converterFactoryName = "pojo-converter-factory")
    public static class CustomPojoCustomEventLogListener extends CustomEventLogListener {}

    @ClientListener(filterFactoryName = "pojo-filter-converter-factory", converterFactoryName = "pojo-filter-converter-factory")
    public static class CustomPojoFilterCustomEventLogListener extends CustomEventLogListener {}

    public static class Person implements Serializable {

        final String name;

        public Person(String name) {
            this.name = name;
        }

        public String getName() {
            return name;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;

            Person person = (Person) o;

            if (!name.equals(person.name)) return false;

            return true;
        }

        @Override
        public int hashCode() {
            return name.hashCode();
        }
    }

    public static class Id implements Serializable {
        final byte id;
        public Id(int id) {
            this.id = (byte) id;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;

            Id id1 = (Id) o;

            if (id != id1.id) return false;

            return true;
        }

        @Override
        public int hashCode() {
            return id;
        }
    }

    protected <T extends Map<String, String>> void fill(T map, int entryCount) {
        for (int i = 0; i != entryCount; i++) {
            map.put("key" + i, "value" + i);
        }
    }
}
