/*
 * 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.io.Serializable;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
import org.apache.ignite.Ignite;
import org.apache.ignite.IgniteCache;
import org.apache.ignite.cache.CacheKeyConfiguration;
import org.apache.ignite.cache.CacheMode;
import org.apache.ignite.cache.QueryEntity;
import org.apache.ignite.cache.QueryIndex;
import org.apache.ignite.cache.affinity.AffinityKeyMapped;
import org.apache.ignite.cache.query.SqlFieldsQuery;
import org.apache.ignite.cache.query.annotations.QuerySqlField;
import org.apache.ignite.configuration.CacheConfiguration;
import org.apache.ignite.configuration.IgniteConfiguration;
import org.apache.ignite.internal.binary.BinaryMarshaller;
import org.apache.ignite.internal.util.typedef.F;
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.testframework.junits.common.GridCommonAbstractTest;

import static org.apache.ignite.cache.CacheMode.PARTITIONED;
import static org.apache.ignite.cache.CacheMode.REPLICATED;
import static org.apache.ignite.cache.CacheWriteSynchronizationMode.FULL_SYNC;

/**
 *
 */
@SuppressWarnings("unchecked")
public class IgniteCacheJoinQueryWithAffinityKeyTest extends GridCommonAbstractTest {
    /** */
    private static final TcpDiscoveryIpFinder IP_FINDER = new TcpDiscoveryVmIpFinder(true);

    /** */
    private static final int NODES = 5;

    /** */
    private boolean client;

    /** */
    private boolean escape;

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

        ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(IP_FINDER);

        CacheKeyConfiguration keyCfg = new CacheKeyConfiguration();

        keyCfg.setTypeName(TestKeyWithAffinity.class.getName());
        keyCfg.setAffinityKeyFieldName("affKey");

        cfg.setCacheKeyConfiguration(keyCfg);

        cfg.setClientMode(client);

        return cfg;
    }

    /** {@inheritDoc} */
    @Override protected void beforeTestsStarted() throws Exception {
        super.beforeTestsStarted();

        startGridsMultiThreaded(NODES - 1);

        client = true;

        startGrid(NODES - 1);
    }

    /** {@inheritDoc} */
    @Override protected void afterTestsStopped() throws Exception {
        stopAllGrids();

        super.afterTestsStopped();
    }

    /**
     * @throws Exception If failed.
     */
    public void testJoinQuery() throws Exception {
        testJoinQuery(PARTITIONED, 0, false, true);

        testJoinQuery(PARTITIONED, 1, false, true);

        testJoinQuery(REPLICATED, 0, false, true);
    }

    /**
     * @throws Exception If failed.
     */
    public void testJoinQueryEscapeAll() throws Exception {
        escape = true;

        testJoinQuery();
    }

    /**
     * @throws Exception If failed.
     */
    public void testJoinQueryWithAffinityKey() throws Exception {
        testJoinQuery(PARTITIONED, 0, true, true);

        testJoinQuery(PARTITIONED, 1, true, true);

        testJoinQuery(REPLICATED, 0, true, true);
    }

    /**
     * @throws Exception If failed.
     */
    public void testJoinQueryWithAffinityKeyEscapeAll() throws Exception {
        escape = true;

        testJoinQueryWithAffinityKey();
    }

    /**
     * @throws Exception If failed.
     */
    public void testJoinQueryWithAffinityKeyNotQueryField() throws Exception {
        testJoinQuery(PARTITIONED, 0, true, false);

        testJoinQuery(PARTITIONED, 1, true, false);

        testJoinQuery(REPLICATED, 0, true, false);
    }

    /**
     * @throws Exception If failed.
     */
    public void testJoinQueryWithAffinityKeyNotQueryFieldEscapeAll() throws Exception {
        escape = true;

        testJoinQueryWithAffinityKeyNotQueryField();
    }

    /**
     * @param cacheMode Cache mode.
     * @param backups Number of backups.
     * @param affKey If {@code true} uses key with affinity key field.
     * @param includeAffKey If {@code true} includes affinity key field in query fields.
     */
    private void testJoinQuery(CacheMode cacheMode, int backups, final boolean affKey, boolean includeAffKey) {
        CacheConfiguration ccfg = cacheConfiguration(cacheMode, backups, affKey, includeAffKey);

        log.info("Test cache [mode=" + cacheMode + ", backups=" + backups + ']');

        IgniteCache cache = ignite(0).createCache(ccfg);

        try {
            final PutData putData = putData(cache, affKey);

            for (int i = 0; i < NODES; i++) {
                log.info("Test node: " + i);

                IgniteCache cache0 = ignite(i).cache(ccfg.getName());

                checkPersonAccountsJoin(cache0, putData.personAccounts, affKey);

                checkOrganizationPersonsJoin(cache0, putData.orgPersons);
            }
        }
        finally {
            ignite(0).destroyCache(ccfg.getName());
        }
    }

    /**
     * @param cache Cache.
     * @param cnts Organizations per person counts.
     */
    private void checkOrganizationPersonsJoin(IgniteCache cache, Map<Integer, Integer> cnts) {
        SqlFieldsQuery qry;

        if (escape) {
            qry = new SqlFieldsQuery("select o.\"name\", p.\"name\" " +
                "from \"Organization\" o, \"Person\" p " +
                "where p.\"orgId\" = o._key and o._key=?");
        }
        else {
            qry = new SqlFieldsQuery("select o.name, p.name " +
                "from Organization o, Person p " +
                "where p.orgId = o._key and o._key=?");
        }

        qry.setDistributedJoins(true);

        long total = 0;

        for (int i = 0; i < cnts.size(); i++) {
            qry.setArgs(i);

            List<List<Object>> res = cache.query(qry).getAll();

            assertEquals((int)cnts.get(i), res.size());

            total += res.size();
        }

        SqlFieldsQuery qry2;

        if (escape) {
            qry2 = new SqlFieldsQuery("select count(*) " +
                "from \"Organization\" o, \"Person\" p where p.\"orgId\" = o._key");
        }
        else {
            qry2 = new SqlFieldsQuery("select count(*) " +
                "from Organization o, Person p where p.orgId = o._key");
        }

        qry2.setDistributedJoins(true);

        List<List<Object>> res = cache.query(qry2).getAll();

        assertEquals(1, res.size());
        assertEquals(total, res.get(0).get(0));
    }

    /**
     * @param cache Cache.
     * @param cnts Accounts per person counts.
     * @param affKey If {@code true} uses key with affinity key field.
     */
    private void checkPersonAccountsJoin(IgniteCache cache, Map<Object, Integer> cnts, boolean affKey) {
        String sql1;

        if (escape) {
            sql1 = "select p.\"name\" from \"Person\" p, \"" + (affKey ? "AccountKeyWithAffinity" : "Account") + "\" a " +
                "where p._key = a.\"personKey\" and p._key=?";
        }
        else {
            sql1 = "select p.name from Person p, " + (affKey ? "AccountKeyWithAffinity" : "Account") + " a " +
                "where p._key = a.personKey and p._key=?";
        }

        SqlFieldsQuery qry1 = new SqlFieldsQuery(sql1);

        qry1.setDistributedJoins(true);

        String sql2;

        if (escape) {
            sql2 = "select p.\"name\" from \"Person\" p, \"" + (affKey ? "AccountKeyWithAffinity" : "Account") + "\" a " +
                "where p.\"id\" = a.\"personId\" and p.\"id\"=?";
        }
        else {
            sql2 = "select p.name from Person p, " + (affKey ? "AccountKeyWithAffinity" : "Account") + " a " +
                "where p.id = a.personId and p.id=?";
        }

        SqlFieldsQuery qry2 = new SqlFieldsQuery(sql2);

        qry2.setDistributedJoins(true);

        Ignite ignite = (Ignite)cache.unwrap(Ignite.class);

        boolean binary = ignite.configuration().getMarshaller() instanceof BinaryMarshaller;

        long total = 0;

        for (Map.Entry<Object, Integer> e : cnts.entrySet()) {
            Object arg = binary ? ignite.binary().toBinary(e.getKey()) : e.getKey();

            qry1.setArgs(arg);

            List<List<Object>> res = cache.query(qry1).getAll();

            assertEquals((int)e.getValue(), res.size());

            total += res.size();

            qry2.setArgs(((Id)e.getKey()).id());

            res = cache.query(qry2).getAll();

            assertEquals((int)e.getValue(), res.size());
        }

        SqlFieldsQuery[] qrys = new SqlFieldsQuery[2];


        if (escape) {
            qrys[0] = new SqlFieldsQuery("select count(*) " +
                "from \"Person\" p, \"" + (affKey ? "AccountKeyWithAffinity" : "Account") + "\" a " +
                "where p.\"id\" = a.\"personId\"");

            qrys[1] = new SqlFieldsQuery("select count(*) " +
                "from \"Person\" p, \"" + (affKey ? "AccountKeyWithAffinity" : "Account") + "\" a " +
                "where p._key = a.\"personKey\"");
        }
        else {
            qrys[0] = new SqlFieldsQuery("select count(*) " +
                "from Person p, " + (affKey ? "AccountKeyWithAffinity" : "Account") + " a " +
                "where p.id = a.personId");

            qrys[1] = new SqlFieldsQuery("select count(*) " +
                "from Person p, " + (affKey ? "AccountKeyWithAffinity" : "Account") + " a " +
                "where p._key = a.personKey");
        }

        for (SqlFieldsQuery qry : qrys) {
            qry.setDistributedJoins(true);

            List<List<Object>> res = cache.query(qry).getAll();

            assertEquals(1, res.size());
            assertEquals(total, res.get(0).get(0));
        }
    }

    /**
     * @param cacheMode Cache mode.
     * @param backups Number of backups.
     * @param affKey If {@code true} uses key with affinity key field.
     * @param includeAffKey If {@code true} includes affinity key field in query fields.
     * @return Cache configuration.
     */
    private CacheConfiguration cacheConfiguration(CacheMode cacheMode,
        int backups,
        boolean affKey,
        boolean includeAffKey) {
        CacheConfiguration ccfg = new CacheConfiguration(DEFAULT_CACHE_NAME);

        ccfg.setCacheMode(cacheMode);

        if (cacheMode == PARTITIONED)
            ccfg.setBackups(backups);

        ccfg.setWriteSynchronizationMode(FULL_SYNC);

        String personKeyType = affKey ? TestKeyWithAffinity.class.getName() : TestKey.class.getName();

        QueryEntity account = new QueryEntity();
        account.setKeyType(Integer.class.getName());
        account.setValueType(affKey ? AccountKeyWithAffinity.class.getName() : Account.class.getName());
        account.addQueryField("personKey", personKeyType, null);
        account.addQueryField("personId", Integer.class.getName(), null);
        account.setIndexes(F.asList(new QueryIndex("personKey"), new QueryIndex("personId")));

        QueryEntity person = new QueryEntity();
        person.setKeyType(personKeyType);
        person.setValueType(Person.class.getName());
        person.addQueryField("orgId", Integer.class.getName(), null);
        person.addQueryField("id", Integer.class.getName(), null);
        person.addQueryField("name", String.class.getName(), null);
        person.setIndexes(F.asList(new QueryIndex("orgId"), new QueryIndex("id"), new QueryIndex("name")));

        if (affKey && includeAffKey)
            person.addQueryField("affKey", Integer.class.getName(), null);

        QueryEntity org = new QueryEntity();
        org.setKeyType(Integer.class.getName());
        org.setValueType(Organization.class.getName());
        org.addQueryField("name", String.class.getName(), null);
        org.setIndexes(F.asList(new QueryIndex("name")));

        ccfg.setQueryEntities(F.asList(account, person, org));

        ccfg.setSqlEscapeAll(escape);

        return ccfg;
    }

    /**
     * @param cache Cache.
     * @param affKey If {@code true} uses key with affinity key field.
     * @return Put data counts.
     */
    private PutData putData(IgniteCache cache, boolean affKey) {
        Map<Integer, Integer> orgPersons = new HashMap<>();
        Map<Object, Integer> personAccounts = new HashMap<>();

        final int ORG_CNT = 10;

        for (int i = 0; i < ORG_CNT; i++)
            cache.put(i, new Organization("org-" + i));

        Set<Integer> personIds = new HashSet<>();
        Set<Integer> accountIds = new HashSet<>();

        for (int i = 0; i < ORG_CNT; i++) {
            int persons = ThreadLocalRandom.current().nextInt(100);

            for (int p = 0; p < persons; p++) {
                int personId = ThreadLocalRandom.current().nextInt();

                while (!personIds.add(personId))
                    personId = ThreadLocalRandom.current().nextInt();

                Object personKey = affKey ? new TestKeyWithAffinity(personId) : new TestKey(personId);

                String name = "person-" + personId;

                cache.put(personKey, new Person(i, name));

                int accounts = ThreadLocalRandom.current().nextInt(10);

                for (int a = 0; a < accounts; a++) {
                    int accountId = ThreadLocalRandom.current().nextInt();

                    while (!accountIds.add(accountId))
                        accountId = ThreadLocalRandom.current().nextInt();

                    cache.put(accountId, affKey ? new AccountKeyWithAffinity(personKey) : new Account(personKey));
                }

                personAccounts.put(personKey, accounts);
            }

            orgPersons.put(i, persons);
        }

        return new PutData(orgPersons, personAccounts);
    }

    /**
     *
     */
    private static class PutData {
        /** */
        final Map<Integer, Integer> orgPersons;

        /** */
        final Map<Object, Integer> personAccounts;

        /**
         * @param orgPersons Organizations per person counts.
         * @param personAccounts Accounts per person counts.
         */
        public PutData(Map<Integer, Integer> orgPersons, Map<Object, Integer> personAccounts) {
            this.orgPersons = orgPersons;
            this.personAccounts = personAccounts;
        }
    }

    /**
     *
     */
    public interface Id {
        /**
         * @return ID.
         */
        public int id();
    }

    /**
     *
     */
    public static class TestKey implements Id {
        /** */
        private int id;

        /**
         * @param id Key.
         */
        public TestKey(int id) {
            this.id = id;
        }

        /** {@inheritDoc} */
        @Override public int id() {
            return id;
        }

        /** {@inheritDoc} */
        @Override public boolean equals(Object o) {
            if (this == o)
                return true;

            if (o == null || getClass() != o.getClass())
                return false;

            TestKey other = (TestKey)o;

            return id == other.id;
        }

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

    /**
     *
     */
    public static class TestKeyWithAffinity implements Id {
        /** */
        private int id;

        /** */
        @AffinityKeyMapped
        private int affKey;

        /**
         * @param id Key.
         */
        public TestKeyWithAffinity(int id) {
            this.id = id;

            affKey = id + 1;
        }

        /** {@inheritDoc} */
        @Override public int id() {
            return id;
        }

        /** {@inheritDoc} */
        @Override public boolean equals(Object o) {
            if (this == o)
                return true;

            if (o == null || getClass() != o.getClass())
                return false;

            TestKeyWithAffinity other = (TestKeyWithAffinity)o;

            return id == other.id;
        }

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

    /**
     *
     */
    private static class Account implements Serializable {
        /** */
        @QuerySqlField
        private TestKey personKey;

        /** */
        @QuerySqlField
        private int personId;

        /**
         * @param personKey Person key.
         */
        public Account(Object personKey) {
            this.personKey = (TestKey)personKey;
            personId = this.personKey.id;
        }
    }

    /**
     *
     */
    private static class AccountKeyWithAffinity implements Serializable {
        /** */
        @QuerySqlField
        private TestKeyWithAffinity personKey;

        /** */
        @QuerySqlField
        private int personId;

        /**
         * @param personKey Person key.
         */
        public AccountKeyWithAffinity(Object personKey) {
            this.personKey = (TestKeyWithAffinity)personKey;
            personId = this.personKey.id;
        }
    }

    /**
     *
     */
    private static class Person implements Serializable {
        /** */
        @QuerySqlField
        int orgId;

        /** */
        @QuerySqlField
        String name;

        /**
         * @param orgId Organization ID.
         * @param name Name.
         */
        public Person(int orgId, String name) {
            this.orgId = orgId;
            this.name = name;
        }
    }

    /**
     *
     */
    private static class Organization implements Serializable {
        /** */
        @QuerySqlField
        String name;

        /**
         * @param name Name.
         */
        public Organization(String name) {
            this.name = name;
        }
    }
}
