/*
 * 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.ignite.internal.processors.cache;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import javax.cache.Cache;
import javax.cache.processor.EntryProcessor;
import javax.cache.processor.MutableEntry;
import org.apache.ignite.IgniteCache;
import org.apache.ignite.cache.CacheEntry;
import org.apache.ignite.cache.CacheInterceptor;
import org.apache.ignite.cache.CacheMode;
import org.apache.ignite.cache.affinity.Affinity;
import org.apache.ignite.configuration.CacheConfiguration;
import org.apache.ignite.configuration.IgniteConfiguration;
import org.apache.ignite.internal.util.typedef.F;
import org.apache.ignite.lang.IgniteBiTuple;
import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi;
import org.apache.ignite.spi.discovery.tcp.ipfinder.TcpDiscoveryIpFinder;
import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder;
import org.apache.ignite.transactions.Transaction;
import org.apache.ignite.transactions.TransactionConcurrency;
import org.apache.ignite.transactions.TransactionIsolation;
import org.jetbrains.annotations.Nullable;
import java.util.concurrent.ConcurrentHashMap;

import static org.apache.ignite.cache.CacheAtomicityMode.TRANSACTIONAL;
import static org.apache.ignite.cache.CacheMode.LOCAL;
import static org.apache.ignite.cache.CacheMode.PARTITIONED;
import static org.apache.ignite.cache.CacheMode.REPLICATED;

/**
 * Tests {@link CacheInterceptor}.
 */
public abstract class GridCacheInterceptorAbstractSelfTest extends GridCacheAbstractSelfTest {
    /** */
    private static final TcpDiscoveryIpFinder IP_FINDER = new TcpDiscoveryVmIpFinder(true);

    /** */
    private static Interceptor interceptor;

    /** {@inheritDoc} */
    @Override protected int gridCount() {
        return 3;
    }

    /** {@inheritDoc} */
    @Override protected void beforeTestsStarted() throws Exception {
        interceptor = new Interceptor();

        super.beforeTestsStarted();

        awaitPartitionMapExchange();
    }

    /** {@inheritDoc} */
    @Override protected void afterTest() throws Exception {
        interceptor.reset();

        interceptor.disabled = true;

        super.afterTest();

        interceptor.disabled = false;

        assertEquals(0, interceptor.invokeCnt.get());
    }

    /** {@inheritDoc} */
    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
        IgniteConfiguration c = super.getConfiguration(igniteInstanceName);

        TcpDiscoverySpi spi = new TcpDiscoverySpi();

        spi.setIpFinder(IP_FINDER);

        c.setDiscoverySpi(spi);

        c.getTransactionConfiguration().setTxSerializableEnabled(true);

        return c;
    }

    /** {@inheritDoc} */
    @Override protected CacheConfiguration cacheConfiguration(String igniteInstanceName) throws Exception {
        CacheConfiguration ccfg = super.cacheConfiguration(igniteInstanceName);

        assertNotNull(interceptor);

        ccfg.setInterceptor(interceptor);

        if (!storeEnabled()) {
            ccfg.setCacheStoreFactory(null);
            ccfg.setReadThrough(false);
            ccfg.setWriteThrough(false);
        }

        return ccfg;
    }

    /** {@inheritDoc} */
    @Override protected CacheMode cacheMode() {
        return PARTITIONED;
    }

    /**
     * @return {@code True} if cache store is enabled.
     */
    protected boolean storeEnabled() {
        return false;
    }

    /**
     * @throws Exception If failed.
     */
    public void testGet() throws Exception {
        testGet(primaryKey(0), false);

        afterTest();

        if (cacheMode() != LOCAL)
            testGet(backupKey(0), false);
    }

    /**
     * @throws Exception If failed.
     */
    public void testGetEntry() throws Exception {
        testGet(primaryKey(0), true);

        afterTest();

        if (cacheMode() != LOCAL)
            testGet(backupKey(0), true);
    }

    /**
     * @param key Key.
     * @param needVer Need version.
     * @throws Exception If failed.
     */
    private void testGet(String key, boolean needVer) throws Exception {
        // Try when value is not in cache.

        interceptor.retInterceptor = new NullGetInterceptor();

        log.info("Get 1.");

        IgniteCache<String, Integer> cache = jcache(0);

        assertEquals(null, needVer ? cache.getEntry(key) : cache.get(key));

        assertEquals(1, interceptor.invokeCnt.get());

        assertEquals(0, interceptor.getMap.size());

        interceptor.reset();

        interceptor.retInterceptor = new OneGetInterceptor();

        log.info("Get 2.");

        assertEquals((Integer)1, needVer ? cache.getEntry(key).getValue() : cache.get(key));

        assertEquals(1, interceptor.invokeCnt.get());

        assertEquals(0, interceptor.getMap.size());

        interceptor.reset();

        // Disable interceptor and update cache.

        interceptor.disabled = true;

        cache.put(key, 100);

        interceptor.disabled = false;

        // Try when value is in cache.

        interceptor.retInterceptor = new NullGetInterceptor();

        log.info("Get 3.");

        assertEquals(null, needVer ? cache.getEntry(key) : cache.get(key));

        assertEquals(1, interceptor.invokeCnt.get());

        assertEquals(1, interceptor.getMap.size());

        assertEquals(100, interceptor.getMap.get(key));

        checkCacheValue(key, 100);

        interceptor.reset();

        interceptor.retInterceptor = new GetIncrementInterceptor();

        log.info("Get 4.");

        assertEquals((Integer)101, needVer ? cache.getEntry(key).getValue() : cache.get(key));

        assertEquals(1, interceptor.invokeCnt.get());

        assertEquals(1, interceptor.getMap.size());

        assertEquals(100, interceptor.getMap.get(key));

        checkCacheValue(key, 100);

        interceptor.reset();

        interceptor.retInterceptor = new GetIncrementInterceptor();

        log.info("GetAsync 1.");


        if (needVer)
            assertEquals((Integer)101, cache.getEntryAsync(key).get().getValue());
        else
            assertEquals((Integer)101, cache.getAsync(key).get());

        assertEquals(1, interceptor.invokeCnt.get());

        assertEquals(1, interceptor.getMap.size());

        assertEquals(100, interceptor.getMap.get(key));

        checkCacheValue(key, 100);
    }

    /**
     * @throws Exception If failed.
     */
    public void testGetAll() throws Exception {
        testGetAll(false);
    }

    /**
     * @throws Exception If failed.
     */
    public void testGetEntries() throws Exception {
        testGetAll(true);
    }

    /**
     * @throws Exception If failed.
     */
    private void testGetAll(boolean needVer) throws Exception {
        Set<String> keys = new LinkedHashSet<>();

        for (int i = 0; i < 1000; i++)
            keys.add(String.valueOf(i));

        interceptor.retInterceptor = new NullGetInterceptor();

        IgniteCache<String, Integer> cache = jcache(0);

        Collection<CacheEntry<String, Integer>> c;
        Map<String, Integer> map;

        if (needVer){
            c = cache.getEntries(keys);

            assertTrue(c.isEmpty());
        }else {
            map = cache.getAll(keys);

            for (String key : keys)
                assertEquals(null, map.get(key));
        }

        assertEquals(1000, interceptor.invokeCnt.get());

        interceptor.reset();

        interceptor.retInterceptor = new GetAllInterceptor1();

        if (needVer) {
            c = cache.getEntries(keys);

            assertEquals(500, c.size());

            for (CacheEntry<String, Integer> e : c) {
                int k = Integer.valueOf(e.getKey());

                assertEquals((Integer)(k * 2), e.getValue());
            }
        }
        else {
            map = cache.getAll(keys);

            for (String key : keys) {
                int k = Integer.valueOf(key);

                if (k % 2 == 0)
                    assertEquals(null, map.get(key));
                else
                    assertEquals((Integer)(k * 2), map.get(key));
            }
        }

        assertEquals(1000, interceptor.invokeCnt.get());

        // Put some values in cache.

        interceptor.disabled = true;

        for (int i = 0; i < 500; i++)
            cache.put(String.valueOf(i), i);

        interceptor.disabled = false;

        for (int j = 0; j < 2; j++) {
            interceptor.reset();

            interceptor.retInterceptor = new GetAllInterceptor2();

            if (needVer) {
                if (j == 0)
                    c = cache.getEntries(keys);
                else
                    c = cache.getEntriesAsync(keys).get();

                for (CacheEntry<String, Integer> e : c) {
                    int k = Integer.valueOf(e.getKey());

                    switch (k % 3) {
                        case 1:
                            Integer exp = k < 500 ? k : null;

                            assertEquals(exp, e.getValue());

                            break;

                        case 2:
                            assertEquals((Integer)(k * 3), e.getValue());

                            break;

                        default:
                            fail();
                    }
                }
            }
            else {
                if (j == 0)
                    map = cache.getAll(keys);
                else
                    map = cache.getAllAsync(keys).get();

                int i = 0;

                for (String key : keys) {
                    switch (i % 3) {
                        case 0:
                            assertEquals(null, map.get(key));

                            break;

                        case 1:
                            Integer exp = i < 500 ? i : null;

                            assertEquals(exp, map.get(key));

                            break;

                        case 2:
                            assertEquals((Integer)(i * 3), map.get(key));

                            break;

                        default:
                            fail();
                    }

                    i++;
                }
            }

            assertEquals(1000, interceptor.invokeCnt.get());
        }
    }

    /**
     * @throws Exception If failed.
     */
    public void testCancelUpdate() throws Exception {
        for (Operation op : Operation.values()) {
            testCancelUpdate(primaryKey(0), op);

            afterTest();

            if (cacheMode() != LOCAL) {
                testCancelUpdate(backupKey(0), op);

                afterTest();
            }
        }
    }

    /**
     * @param op Operation type.
     * @return {@code True} if this is atomic cache and update is first run on primary node.
     */
    private int expectedIgnoreInvokeCount(Operation op) {
        int dataNodes = cacheMode() == REPLICATED ? gridCount() : 2;

        if (atomicityMode() == TRANSACTIONAL)
            return dataNodes + (storeEnabled() ? 1 : 0); // One call before store is updated.
        else {
            // If update goes through primary node and it is cancelled then backups aren't updated.
            return op == Operation.TRANSFORM ? 1 : dataNodes;
        }
    }

    /**
     * @param op Operation type.
     * @return {@code True} if this is atomic cache and update is first run on primary node.
     */
    private int expectedInvokeCount(Operation op) {
        int dataNodes = cacheMode() == REPLICATED ? gridCount() : 2;

        if (atomicityMode() == TRANSACTIONAL)
            // Update + after update + one call before store is updated.
            return dataNodes * 2 + (storeEnabled() ? 1 : 0);
        else
            return op == Operation.TRANSFORM ? 2 : dataNodes * 2;
    }

    /**
     * @param key Key.
     * @param op Operation type.
     * @throws Exception If failed.
     */
    private void testCancelUpdate(String key, Operation op) throws Exception {
        // Interceptor returns null to disabled update.
        CacheInterceptor retInterceptor = new NullPutInterceptor();

        interceptor.retInterceptor = retInterceptor;

        // Execute update when value is null, it should not change cache value.

        log.info("Update 1 " + op);

        update(0, op, key, 1, null);

        checkCacheValue(key, null);

        // Check values passed to interceptor.

        assertEquals(1, interceptor.beforePutMap.size());

        IgniteBiTuple t = interceptor.beforePutMap.get(key);

        assertEquals(null, t.get1());
        assertEquals(1, t.get2());

        // Disable interceptor and update cache.

        interceptor.reset();

        interceptor.disabled = true;

        clearCaches();

        jcache(0).put(key, 1);

        checkCacheValue(key, 1);

        // Execute update when value is not null, it should not change cache value.

        interceptor.disabled = false;
        interceptor.retInterceptor = retInterceptor;

        log.info("Update 2 " + op);

        update(0, op, key, 2, 1);

        checkCacheValue(key, 1);

        // Check values passed to interceptor.

        assertEquals(1, interceptor.beforePutMap.size());

        t = interceptor.beforePutMap.get(key);

        assertEquals(1, t.get1());
        assertEquals(2, t.get2());
    }

    /**
     * @throws Exception If failed.
     */
    public void testModifyUpdate() throws Exception {
        for (Operation op : Operation.values()) {
            testModifyUpdate(primaryKey(0), op);

            afterTest();

            if (cacheMode() != LOCAL) {
                testModifyUpdate(backupKey(0), op);

                afterTest();
            }
        }
    }

    /**
     * @param key Key.
     * @param op Operation type.
     * @throws Exception If failed.
     */
    private void testModifyUpdate(String key, Operation op) throws Exception {
        // Interceptor returns incremented new value.
        CacheInterceptor retInterceptor = new PutIncrementInterceptor();

        // Execute update when value is null.

        interceptor.retInterceptor = retInterceptor;

        log.info("Update 1 " + op);

        update(0, op, key, 1, null);

        checkCacheValue(key, 2);

        // Check values passed to interceptor.

        assertEquals(1, interceptor.beforePutMap.size());

        IgniteBiTuple t = interceptor.beforePutMap.get(key);

        assertEquals(null, t.get1());
        assertEquals(1, t.get2());

        assertEquals(1, interceptor.afterPutMap.size());

        assertEquals(2, interceptor.afterPutMap.get(key));

        // Execute update when value is not null.

        interceptor.reset();

        interceptor.retInterceptor = retInterceptor;

        log.info("Update 2 " + op);

        update(0, op, key, 3, 2);

        checkCacheValue(key, 4);

        // Check values passed to interceptor.

        assertEquals(1, interceptor.beforePutMap.size());

        t = interceptor.beforePutMap.get(key);

        assertEquals(2, t.get1());
        assertEquals(3, t.get2());

        assertEquals(1, interceptor.afterPutMap.size());

        assertEquals(4, interceptor.afterPutMap.get(key));
    }

    /**
     * @throws Exception If failed.
     */
    public void testCancelRemove() throws Exception {
        for (Operation op : Operation.values()) {
            testCancelRemove(primaryKey(0), op);

            afterTest();

            if (cacheMode() != LOCAL) {
                testCancelRemove(backupKey(0), op);

                afterTest();
            }
        }
    }

    /**
     * @param key Key.
     * @param op Operation type.
     * @throws Exception If failed.
     */
    @SuppressWarnings("unchecked")
    private void testCancelRemove(String key, Operation op) throws Exception {
        // Interceptor disables remove and returns null.
        interceptor.retInterceptor = new BeforeRemoveInterceptor(new IgniteBiTuple(true, null));

        // Execute remove when value is null.

        log.info("Remove 1 " + op);

        remove(0, op, key, null, null);

        checkCacheValue(key, null);

        // Check values passed to interceptor.

        assertEquals(0, interceptor.beforeRmvMap.size());

        assertEquals(null, interceptor.beforeRmvMap.get(key));

        log.info("Remove 2 " + op);

        interceptor.reset();

        // Interceptor disables remove and changes return value.
        interceptor.retInterceptor = new BeforeRemoveInterceptor(new IgniteBiTuple(true, 900));

        // Execute remove when value is null, interceptor changes return value.

        remove(0, op, key, null, 900);

        checkCacheValue(key, null);

        // Check values passed to interceptor.

        assertEquals(0, interceptor.beforeRmvMap.size());

        assertEquals(null, interceptor.beforeRmvMap.get(key));

        // Disable interceptor and update cache.

        interceptor.reset();

        interceptor.disabled = true;

        clearCaches();

        jcache(0).put(key, 1);

        checkCacheValue(key, 1);

        // Execute remove when value is not null, it should not change cache value.

        interceptor.reset();

        interceptor.disabled = false;

        // Interceptor disables remove and returns null.
        interceptor.retInterceptor = new BeforeRemoveInterceptor(new IgniteBiTuple(true, null));

        log.info("Remove 3 " + op);

        remove(0, op, key, 1, null);

        checkCacheValue(key, 1);

        // Check values passed to interceptor.

        assertEquals(1, interceptor.beforeRmvMap.size());

        assertEquals(1, interceptor.beforeRmvMap.get(key));

        interceptor.reset();

        // Interceptor disables remove and changes return value.
        interceptor.retInterceptor = new BeforeRemoveInterceptor(new IgniteBiTuple(true, 1000));

        log.info("Remove 4 " + op);

        remove(0, op, key, 1, 1000);

        checkCacheValue(key, 1);

        // Check values passed to interceptor.

        assertEquals(1, interceptor.beforeRmvMap.size());

        assertEquals(1, interceptor.beforeRmvMap.get(key));
    }

    /**
     * @throws Exception If failed.
     */
    public void testRemove() throws Exception {
        for (Operation op : Operation.values()) {
            testRemove(primaryKey(0), op);

            afterTest();

            if (cacheMode() != LOCAL) {
                testRemove(backupKey(0), op);

                afterTest();
            }
        }
    }

    /**
     * @param key Key.
     * @param op Operation type.
     * @throws Exception If failed.
     */
    @SuppressWarnings("unchecked")
    private void testRemove(String key, Operation op) throws Exception {
        // Interceptor changes return value to null.
        interceptor.retInterceptor = new BeforeRemoveInterceptor( new IgniteBiTuple(false, null));

        // Execute remove when value is null.

        log.info("Remove 1 " + op);

        remove(0, op, key, null, null);

        checkCacheValue(key, null);

        // Check values passed to interceptor.

        assertEquals(0, interceptor.beforeRmvMap.size());

        assertEquals(0, interceptor.afterRmvMap.size());

        log.info("Remove 2 " + op);

        interceptor.reset();

        // Interceptor changes return value.
        interceptor.retInterceptor = new BeforeRemoveInterceptor(new IgniteBiTuple(false, 900));

        // Execute remove when value is null.

        remove(0, op, key, null, 900);

        checkCacheValue(key, null);

        // Check values passed to interceptor.

        assertEquals(0, interceptor.beforeRmvMap.size());

        assertEquals(0, interceptor.afterRmvMap.size());

        // Disable interceptor and update cache.

        interceptor.reset();

        interceptor.disabled = true;

        clearCaches();

        jcache(0).put(key, 1);

        checkCacheValue(key, 1);

        // Execute remove when value is not null.

        interceptor.reset();

        interceptor.disabled = false;

        // Interceptor changes return value to null.
        interceptor.retInterceptor = new BeforeRemoveInterceptor(new IgniteBiTuple(false, null));

        log.info("Remove 3 " + op);

        remove(0, op, key, 1, null);

        checkCacheValue(key, null);

        // Check values passed to interceptor.

        assertEquals(1, interceptor.beforeRmvMap.size());

        assertEquals(1, interceptor.beforeRmvMap.get(key));

        assertEquals(1, interceptor.afterRmvMap.size());

        assertEquals(1, interceptor.afterRmvMap.get(key));

        // Disable interceptor and update cache.

        interceptor.disabled = true;

        clearCaches();

        jcache(0).put(key, 2);

        checkCacheValue(key, 2);

        // Execute remove when value is not null.

        interceptor.reset();

        interceptor.disabled = false;

        // Interceptor changes return value.
        interceptor.retInterceptor = new BeforeRemoveInterceptor(new IgniteBiTuple(false, 1000));

        log.info("Remove 4 " + op);

        remove(0, op, key, 2, 1000);

        checkCacheValue(key, null);

        // Check values passed to interceptor.

        assertEquals(1, interceptor.beforeRmvMap.size());

        assertEquals(2, interceptor.beforeRmvMap.get(key));

        assertEquals(1, interceptor.afterRmvMap.size());

        assertEquals(2, interceptor.afterRmvMap.get(key));
    }

    /**
     * @throws Exception If failed.
     */
    public void testNearNodeKey() throws Exception {
        if (cacheMode() != PARTITIONED)
            return;

        if (atomicityMode() == TRANSACTIONAL) {
            for (TransactionConcurrency txConcurrency : TransactionConcurrency.values()) {
                for (TransactionIsolation txIsolation : TransactionIsolation.values()) {
                    for (Operation op : Operation.values()) {
                        testNearNodeKey(txConcurrency, txIsolation, op);

                        afterTest();
                    }
                }
            }
        }

        testNearNodeKey(null, null, null);
    }

    /**
     * @param txConcurrency Transaction concurrency.
     * @param txIsolation Transaction isolation.
     * @param op Operation type.
     * @throws Exception If failed.
     */
    private void testNearNodeKey(@Nullable TransactionConcurrency txConcurrency,
        @Nullable TransactionIsolation txIsolation, @Nullable Operation op) throws Exception {
        // Interceptor returns incremented new value.
        interceptor.retInterceptor = new PutIncrementInterceptor();

        String key1 = primaryKey(0);
        String key2 = backupKey(0);
        String key3 = nearKey(0);

        interceptor.disabled = true;

        // Put from grid 1 to be sure grid 0 does not have value for near key.
        jcache(1).putAll(F.asMap(key1, 1, key2, 2, key3, 3));

        interceptor.disabled = false;

        log.info("Update [op=" + op + ", key1=" + key1 + ", key2=" + key2 + ", key3=" + key3 +
            ", txConcurrency=" + txConcurrency + ", txIsolation=" + txIsolation + ']');

        if (txConcurrency != null) {
            assertNotNull(txIsolation);
            assertNotNull(op);

            try (Transaction tx = ignite(0).transactions().txStart(txConcurrency, txIsolation)) {
                update(0, op, key1, 100, 1);
                update(0, op, key2, 200, 2);
                update(0, op, key3, 300, 3);

                tx.commit();
            }
        }
        else
            jcache(0).putAll(F.asMap(key1, 100, key2, 200, key3, 300));

        checkCacheValue(key1, 101);
        checkCacheValue(key2, 201);
        checkCacheValue(key3, 301);
    }

    /**
     * @throws Exception If failed.
     */
    public void testBatchUpdate() throws Exception {
        testBatchUpdate(Operation.UPDATE);

        afterTest();

        testBatchUpdate(Operation.TRANSFORM);
    }

    /**
     * @param op Operation type.
     * @throws Exception If failed.
     */
    private void testBatchUpdate(Operation op) throws Exception {
        // Interceptor returns incremented new value.
        interceptor.retInterceptor = new PutIncrementInterceptor();

        Map<String, Integer> map = new HashMap<>();

        final String key1;
        String key2;
        String key3;

        if (cacheMode() == LOCAL) {
            key1 = "1";
            key2 = "2";
            key3 = "3";
        }
        else {
            List<String> keys = primaryKeys(0, 2);
            key1 = keys.get(0); // Need two keys for the same node to test atomic cache batch store upadte.
            key2 = keys.get(1);
            key3 = backupKey(0);
        }

        map.put(key1, 1);
        map.put(key2, 2);
        map.put(key3, 3);

        log.info("Batch update 1: " + op);

        batchUpdate(0, op, map);

        checkCacheValue(key1, 2);
        checkCacheValue(key2, 3);
        checkCacheValue(key3, 4);

        assertEquals(3, interceptor.beforePutMap.size());

        assertBeforePutValue(key1, null, 1);
        assertBeforePutValue(key2, null, 2);
        assertBeforePutValue(key3, null, 3);

        assertEquals(3, interceptor.afterPutMap.size());

        assertEquals(2, interceptor.afterPutMap.get(key1));
        assertEquals(3, interceptor.afterPutMap.get(key2));
        assertEquals(4, interceptor.afterPutMap.get(key3));

        interceptor.reset();

        // Interceptor returns incremented new value, cancels update for one key.
        interceptor.retInterceptor = new BatchPutInterceptor1(key1);

        map.put(key1, 100);
        map.put(key2, 200);
        map.put(key3, 300);

        log.info("Batch update 2: " + op);

        batchUpdate(0, op, map);

        checkCacheValue(key1, 2);
        checkCacheValue(key2, 201);
        checkCacheValue(key3, 301);

        assertEquals(3, interceptor.beforePutMap.size());

        assertBeforePutValue(key1, 2, 100);
        assertBeforePutValue(key2, 3, 200);
        assertBeforePutValue(key3, 4, 300);

        assertEquals(2, interceptor.afterPutMap.size());

        assertEquals(201, interceptor.afterPutMap.get(key2));
        assertEquals(301, interceptor.afterPutMap.get(key3));
    }

    /**
     * @throws Exception If failed.
     */
    public void testBatchRemove() throws Exception {
        testBatchRemove(Operation.UPDATE);

        afterTest();

        testBatchRemove(Operation.TRANSFORM);
    }

    /**
     * @param op Operation type.
     * @throws Exception If failed.
     */
    @SuppressWarnings("unchecked")
    private void testBatchRemove(Operation op) throws Exception {
        Map<String, Integer> map = new HashMap<>();

        final String key1;
        String key2;
        String key3;

        if (cacheMode() == LOCAL) {
            key1 = "1";
            key2 = "2";
            key3 = "3";
        }
        else {
            List<String> keys = primaryKeys(0, 2);
            key1 = keys.get(0);
            key2 = keys.get(1);
            key3 = backupKey(0);
        }

        map.put(key1, 1);
        map.put(key2, 2);
        map.put(key3, 3);

        // Interceptor does not cancel update.
        interceptor.retInterceptor = new BeforeRemoveInterceptor(new IgniteBiTuple(false, 999));

        log.info("Batch remove 1: " + op);

        batchRemove(0, op, map);

        checkCacheValue(key1, null);
        checkCacheValue(key2, null);
        checkCacheValue(key3, null);

        assertEquals(0, interceptor.beforeRmvMap.size());

        assertEquals(0, interceptor.afterRmvMap.size());

        // Disable interceptor and put some values in cache.

        interceptor.disabled = true;

        jcache(0).putAll(map);

        interceptor.disabled = false;

        interceptor.reset();

        // Interceptor does not cancel update.
        interceptor.retInterceptor = new BeforeRemoveInterceptor(new IgniteBiTuple(false, 999));

        log.info("Batch remove 2: " + op);

        batchRemove(0, op, map);

        checkCacheValue(key1, null);
        checkCacheValue(key2, null);
        checkCacheValue(key3, null);

        assertEquals(3, interceptor.beforeRmvMap.size());

        assertEquals(1, interceptor.beforeRmvMap.get(key1));
        assertEquals(2, interceptor.beforeRmvMap.get(key2));
        assertEquals(3, interceptor.beforeRmvMap.get(key3));

        assertEquals(3, interceptor.afterRmvMap.size());

        assertEquals(1, interceptor.afterRmvMap.get(key1));
        assertEquals(2, interceptor.afterRmvMap.get(key2));
        assertEquals(3, interceptor.afterRmvMap.get(key3));

        // Disable interceptor and put some values in cache.

        interceptor.disabled = true;

        jcache(0).putAll(map);

        interceptor.disabled = false;

        interceptor.reset();

        // Interceptor cancels update for one key.
        interceptor.retInterceptor = new BatchRemoveInterceptor(key1);

        log.info("Batch remove 3: " + op);

        batchRemove(0, op, map);

        checkCacheValue(key1, 1);
        checkCacheValue(key2, null);
        checkCacheValue(key3, null);

        assertEquals(3, interceptor.beforeRmvMap.size());

        assertEquals(1, interceptor.beforeRmvMap.get(key1));
        assertEquals(2, interceptor.beforeRmvMap.get(key2));
        assertEquals(3, interceptor.beforeRmvMap.get(key3));

        assertEquals(2, interceptor.afterRmvMap.size());

        assertEquals(2, interceptor.afterRmvMap.get(key2));
        assertEquals(3, interceptor.afterRmvMap.get(key3));
    }

    /**
     * @param key Key.
     * @param oldVal Expected old value.
     * @param newVal Expected new value.
     */
    private void assertBeforePutValue(String key, @Nullable Object oldVal, @Nullable Object newVal) {
        IgniteBiTuple t = interceptor.beforePutMap.get(key);

        assertNotNull(t);
        assertEquals(t.get1(), oldVal);
        assertEquals(t.get2(), newVal);
    }

    /**
     * @param grid Grid index.
     * @param op Operation type.
     * @param key Key.
     * @param val Value.
     * @param expOld Expected expOld value.
     * @throws Exception If failed.
     */
    @SuppressWarnings("unchecked")
    private void update(int grid, Operation op, String key, final Integer val, @Nullable final Integer expOld)
        throws Exception {
        cacheUpdate(grid, false, op, key, val, expOld, null);
    }

    /**
     * @param grid Grid index.
     * @param op Operation type.
     * @param key Key.
     * @param expOld Expected expOld value.
     * @param expRmvRet Expected remove result.
     * @throws Exception If failed.
     */
    @SuppressWarnings("unchecked")
    private void remove(int grid, Operation op, String key, @Nullable final Integer expOld,
        @Nullable final Integer expRmvRet) throws Exception {
        cacheUpdate(grid, true, op, key, null, expOld, expRmvRet);
    }

    /**
     * @param grid Grid index.
     * @param rmv If {@code true} then executes remove.
     * @param op Operation type.
     * @param key Key.
     * @param val Value.
     * @param expOld Expected expOld value.
     * @param expRmvRet Expected remove result.
     * @throws Exception If failed.
     */
    @SuppressWarnings("unchecked")
    private void cacheUpdate(int grid, boolean rmv, Operation op, String key, final Integer val,
        @Nullable final Integer expOld, @Nullable final Integer expRmvRet)
        throws Exception {
        IgniteCache<String, Integer> cache = jcache(grid);

        if (rmv) {
            assertNull(val);

            switch (op) {
                case UPDATE: {
                    assertEquals(expRmvRet, cache.getAndRemove(key));

                    break;
                }

                case UPDATEX: {
                    cache.remove(key);

                    break;
                }

                case TRANSFORM: {
                    cache.invoke(key, new EntryProcessor<String, Integer, Void>() {
                        @Override public Void process(MutableEntry<String, Integer> e, Object... args) {
                            Integer old = e.getValue();

                            assertEquals(expOld, old);

                            e.remove();

                            return null;
                        }
                    });

                    break;
                }

                default:
                    fail();
            }
        }
        else {
            switch (op) {
                case UPDATE: {
                    assertEquals(expOld, cache.getAndPut(key, val));

                    break;
                }

                case UPDATEX: {
                    cache.put(key, val);

                    break;
                }

                case TRANSFORM: {
                    cache.invoke(key, new EntryProcessor<String, Integer, Void>() {
                        @Override public Void process(MutableEntry<String, Integer> e, Object... args) {
                            Integer old = e.getValue();

                            assertEquals(expOld, old);

                            e.setValue(val);

                            return null;
                        }
                    });

                    break;
                }

                default:
                    fail();
            }
        }
    }

    /**
     * @param grid Grid index.
     * @param op Operation type.
     * @param map Key/values map.
     * @throws Exception If failed.
     */
    @SuppressWarnings("unchecked")
    private void batchUpdate(int grid, Operation op, final Map<String, Integer> map) throws Exception {
        cacheBatchUpdate(grid, false, op, map);
    }

    /**
     * @param grid Grid index.
     * @param op Operation type.
     * @param map Key/values map.
     * @throws Exception If failed.
     */
    @SuppressWarnings("unchecked")
    private void batchRemove(int grid, Operation op, final Map<String, Integer> map) throws Exception {
        cacheBatchUpdate(grid, true, op, map);
    }

    /**
     * @param grid Grid index.
     * @param rmv If {@code true} then executes remove.
     * @param op Operation type.
     * @param map Key/values map.
     * @throws Exception If failed.
     */
    @SuppressWarnings("unchecked")
    private void cacheBatchUpdate(int grid, boolean rmv, Operation op, final Map<String, Integer> map)
        throws Exception {
        IgniteCache<String, Integer> cache = jcache(grid);

        if (rmv) {
            switch (op) {
                case UPDATE: {
                    cache.removeAll(map.keySet());

                    break;
                }

                case TRANSFORM: {
                    cache.invokeAll(map.keySet(), new EntryProcessor<String, Integer, Void>() {
                        @Override public Void process(MutableEntry<String, Integer> e, Object... args) {
                            e.remove();

                            return null;
                        }
                    });

                    break;
                }

                default:
                    fail();
            }
        }
        else {
            switch (op) {
                case UPDATE: {
                    cache.putAll(map);

                    break;
                }

                case TRANSFORM: {
                    cache.invokeAll(map.keySet(), new EntryProcessor<String, Integer, Void>() {
                        @Override public Void process(MutableEntry<String, Integer> e, Object... args) {
                            e.setValue(map.get(e.getKey()));

                            return null;
                        }
                    });

                    break;
                }

                default:
                    fail();
            }
        }
    }

    /**
     * @param idx Grid index.
     * @return Primary key for grid.
     */
    private String primaryKey(int idx) {
        return primaryKeys(idx, 1).get(0);
    }

    /**
     * @param idx Grid index.
     * @param cnt Number of keys.
     * @return Primary keys for grid.
     */
    private List<String> primaryKeys(int idx, int cnt) {
        assert cnt > 0;

        Affinity aff = ignite(0).affinity(DEFAULT_CACHE_NAME);

        List<String> keys = new ArrayList<>(cnt);

        for (int i = 0; i < 10_000; i++) {
            String key = String.valueOf(i);

            if (aff.isPrimary(grid(idx).localNode(), key)) {
                keys.add(key);

                if (keys.size() == cnt)
                    break;
            }
        }

        assertEquals(cnt, keys.size());

        return keys;
    }

    /**
     * @param idx Grid index.
     * @return Primary key for grid.
     */
    private String backupKey(int idx) {
        Affinity aff = ignite(0).affinity(DEFAULT_CACHE_NAME);

        String key = null;

        for (int i = 0; i < 10_000; i++) {
            if (aff.isBackup(grid(idx).localNode(), String.valueOf(i))) {
                key = String.valueOf(i);

                break;
            }
        }

        assertNotNull(key);

        return key;
    }

    /**
     * @param idx Grid index.
     * @return Key which does not belong to the grid.
     */
    private String nearKey(int idx) {
        Affinity aff = ignite(0).affinity(DEFAULT_CACHE_NAME);

        String key = null;

        for (int i = 0; i < 10_000; i++) {
            if (!aff.isPrimaryOrBackup(grid(idx).localNode(), String.valueOf(i))) {
                key = String.valueOf(i);

                break;
            }
        }

        assertNotNull(key);

        return key;
    }

    /**
     * @param key Key.
     * @param expVal Expected value.
     * @throws Exception If failed.
     */
    private void checkCacheValue(Object key, @Nullable Object expVal) throws Exception {
        interceptor.disabled = true;

        if (storeEnabled())
            assertEquals("Unexpected store value", expVal, storeStgy.getFromStore(key));

        try {
            for (int i = 0; i < gridCount(); i++)
                assertEquals("Unexpected value for grid " + i, expVal, grid(i).cache(DEFAULT_CACHE_NAME).get(key));
        }
        finally {
            interceptor.disabled = false;
        }
    }

    /**
     * @throws Exception If failed.
     */
    private void clearCaches() throws Exception {
        for (int i = 0; i < gridCount(); i++)
            jcache(i).removeAll();
    }

    /**
     *
     */
    private enum Operation {
        /**
         *
         */
        UPDATE,

        /**
         *
         */
        UPDATEX,

        /**
         *
         */
        TRANSFORM,
    }

    /**
     *
     */
    private static class InterceptorAdapter implements CacheInterceptor {
        /** */
        @Nullable @Override public Object onGet(Object key, Object val) {
            fail("onGet not expected");

            return null;
        }

        /** */
        @Nullable @Override public Object onBeforePut(Cache.Entry entry, Object newVal) {
            fail("onBeforePut not expected");

            return null;
        }

        /** */
        @Override public void onAfterPut(Cache.Entry entry) {
            fail("onAfterPut not expected");
        }

        /** */
        @Nullable @Override public IgniteBiTuple onBeforeRemove(Cache.Entry entry) {
            fail("onBeforeRemove not expected");

            return null;
        }

        /** */
        @Override public void onAfterRemove(Cache.Entry entry) {
            fail("onAfterRemove not expected");
        }
    }

    /**
     *
     */
    private static class BeforeRemoveInterceptor extends InterceptorAdapter {
        /**
         *
         */
        private IgniteBiTuple ret;

        /**
         * @param ret Return value.
         */
        private BeforeRemoveInterceptor(IgniteBiTuple ret) {
            this.ret = ret;
        }

        /** {@inheritDoc} */
        @Nullable @Override public IgniteBiTuple onBeforeRemove(Cache.Entry entry) {
            return ret;
        }
    }

    /**
     *
     */
    private static class Interceptor implements CacheInterceptor {
        /** */
        private final Map<Object, Object> getMap = new ConcurrentHashMap<>();

        /** */
        private final Map<Object, Object> afterPutMap = new ConcurrentHashMap<>();

        /** */
        private final Map<Object, IgniteBiTuple> beforePutMap = new ConcurrentHashMap<>();

        /** */
        private final Map<Object, Object> beforeRmvMap = new ConcurrentHashMap<>();

        /** */
        private final Map<Object, Object> afterRmvMap = new ConcurrentHashMap<>();

        /** */
        private final AtomicInteger invokeCnt = new AtomicInteger();

        /** */
        private volatile boolean disabled;

        /** */
        private volatile CacheInterceptor retInterceptor;

        /** {@inheritDoc} */
        @SuppressWarnings("unchecked")
        @Nullable @Override public Object onGet(Object key, Object val) {
            if (disabled)
                return val;

            assertNotNull(retInterceptor);

            Object ret = retInterceptor.onGet(key, val);

            System.out.println("Get [key=" + key + ", val=" + val + ", ret=" + ret + ']');

            if (val != null) {
                Object old = getMap.put(key, val);

                assertNull(old); // Fot get interceptor is called on near node only.
            }

            invokeCnt.incrementAndGet();

            return ret;
        }

        /** {@inheritDoc} */
        @SuppressWarnings("unchecked")
        @Nullable @Override public Object onBeforePut(Cache.Entry entry, Object newVal) {
            if (disabled)
                return newVal;

            assertNotNull(retInterceptor);

            Object ret = retInterceptor.onBeforePut(entry, newVal);

            System.out.println("Before put [key=" + entry.getKey() + ", oldVal=" + entry.getValue()+ ", newVal=" + newVal
                + ", ret=" + ret + ']');

            invokeCnt.incrementAndGet();

            IgniteBiTuple t = beforePutMap.put(entry.getKey(), new IgniteBiTuple(entry.getValue(), newVal));

            if (t != null) {
                assertEquals("Interceptor called with different old values for key " + entry.getKey(), t.get1(),
                    entry.getValue());
                assertEquals("Interceptor called with different new values for key " + entry.getKey(), t.get2(),
                    newVal);
            }

            return ret;
        }

        /** {@inheritDoc} */
        @Override public void onAfterPut(Cache.Entry entry) {
            if (disabled)
                return;

            System.out.println("After put [key=" + entry.getKey() + ", val=" + entry.getValue() + ']');

            invokeCnt.incrementAndGet();

            Object old = afterPutMap.put(entry.getKey(), entry.getValue());

            if (old != null)
                assertEquals(old, entry.getValue());
        }

        /** {@inheritDoc} */
        @SuppressWarnings("unchecked")
        @Override @Nullable public IgniteBiTuple onBeforeRemove(Cache.Entry entry) {
            if (disabled)
                return new IgniteBiTuple(false, entry.getValue());

            assertNotNull(retInterceptor);

            IgniteBiTuple ret = retInterceptor.onBeforeRemove(entry);

            System.out.println("Before remove [key=" + entry.getKey() + ", val=" + entry.getValue() + ", ret=" + ret + ']');

            invokeCnt.incrementAndGet();

            if (entry.getValue() != null) {
                Object old = beforeRmvMap.put(entry.getKey(), entry.getValue());

                if (old != null)
                    assertEquals(old, entry.getValue());
            }

            return ret;
        }

        /** {@inheritDoc} */
        @Override public void onAfterRemove(Cache.Entry entry) {
            if (disabled)
                return;

            System.out.println("After remove [key=" + entry.getKey() + ", val=" + entry.getValue() + ']');

            invokeCnt.incrementAndGet();

            if (entry.getValue() != null) {
                Object old = afterRmvMap.put(entry.getKey(), entry.getValue());

                if (old != null)
                    assertEquals(old, entry.getValue());
            }
        }

        /**
         *
         */
        public void reset() {
            invokeCnt.set(0);

            getMap.clear();
            beforePutMap.clear();
            afterPutMap.clear();
            afterRmvMap.clear();
            beforeRmvMap.clear();

            retInterceptor = null;
        }
    }

    /**
     *
     */
    private static class BatchRemoveInterceptor extends InterceptorAdapter {
        /** */
        private final String key1;

        /**
         * @param key1 Key.
         */
        public BatchRemoveInterceptor(String key1) {
            this.key1 = key1;
        }

        /** {@inheritDoc} */
        @Nullable @Override public IgniteBiTuple onBeforeRemove(Cache.Entry entry) {
            return new IgniteBiTuple(entry.getKey().equals(key1), 999);
        }
    }

    /**
     *
     */
    private static class PutIncrementInterceptor extends InterceptorAdapter {
        /** {@inheritDoc} */
        @Nullable @Override public Object onBeforePut(Cache.Entry entry, Object newVal) {
            return (Integer)newVal + 1;
        }
    }

    /**
     *
     */
    private static class NullPutInterceptor extends InterceptorAdapter {
        /** {@inheritDoc} */
        @Nullable @Override public Object onBeforePut(Cache.Entry entry, Object newVal) {
            return null;
        }
    }

    /**
     *
     */
    private static class NullGetInterceptor extends InterceptorAdapter {
        /** {@inheritDoc} */
        @Nullable @Override public Object onGet(Object key, Object val) {
            return null;
        }
    }

    /**
     *
     */
    private static class GetAllInterceptor1 extends InterceptorAdapter {
        /** {@inheritDoc} */
        @Nullable @Override public Object onGet(Object key, Object val) {
            int k = Integer.valueOf((String)key);

            return k % 2 == 0 ? null : (k * 2);
        }
    }

    /**
     *
     */
    private static class GetAllInterceptor2 extends InterceptorAdapter {
        /** {@inheritDoc} */
        @Nullable @Override public Object onGet(Object key, Object val) {
            int k = Integer.valueOf((String)key);

            switch (k % 3) {
                case 0:
                    return null;

                case 1:
                    return val;

                case 2:
                    return k * 3;

                default:
                    fail();
            }

            return null;
        }
    }

    /**
     *
     */
    private static class BatchPutInterceptor1 extends InterceptorAdapter {
        /** */
        private final String key1;

        /**
         * @param key1 Key.
         */
        public BatchPutInterceptor1(String key1) {
            this.key1 = key1;
        }

        /** {@inheritDoc} */
        @Nullable @Override public Object onBeforePut(Cache.Entry entry, Object newVal) {
            if (entry.getKey().equals(key1))
                return null;

            return (Integer)newVal + 1;
        }
    }

    /**
     *
     */
    private static class GetIncrementInterceptor extends InterceptorAdapter {
        /** {@inheritDoc} */
        @Nullable @Override public Object onGet(Object key, Object val) {
            return (Integer)val + 1;
        }
    }

    /**
     *
     */
    private static class OneGetInterceptor extends InterceptorAdapter {
        /** {@inheritDoc} */
        @Nullable @Override public Object onGet(Object key, Object val) {
            return 1;
        }
    }
}