/*
 * 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.geode.cache.query.internal.index;

import static org.apache.geode.cache.Region.SEPARATOR;
import static org.junit.Assert.assertTrue;

import java.io.Serializable;
import java.text.ParseException;
import java.util.HashMap;
import java.util.Map;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.experimental.categories.Category;

import org.apache.geode.cache.AttributesFactory;
import org.apache.geode.cache.Cache;
import org.apache.geode.cache.DataPolicy;
import org.apache.geode.cache.Region;
import org.apache.geode.cache.RegionAttributes;
import org.apache.geode.cache.query.CacheUtils;
import org.apache.geode.cache.query.FunctionDomainException;
import org.apache.geode.cache.query.Index;
import org.apache.geode.cache.query.IndexExistsException;
import org.apache.geode.cache.query.IndexNameConflictException;
import org.apache.geode.cache.query.NameResolutionException;
import org.apache.geode.cache.query.QueryInvocationTargetException;
import org.apache.geode.cache.query.QueryService;
import org.apache.geode.cache.query.RegionNotFoundException;
import org.apache.geode.cache.query.SelectResults;
import org.apache.geode.cache.query.TypeMismatchException;
import org.apache.geode.cache.query.functional.StructSetOrResultsSet;
import org.apache.geode.test.junit.categories.OQLIndexTest;

@Category({OQLIndexTest.class})
public class EquiJoinIntegrationTest {
  QueryService qs;
  Region region1, region2, region3, region4;

  @Before
  public void setUp() throws java.lang.Exception {
    CacheUtils.startCache();
    qs = CacheUtils.getQueryService();
  }

  @After
  public void tearDown() {
    region2.destroyRegion();
    region1.destroyRegion();
  }

  protected void createRegions() throws Exception {
    region1 = createReplicatedRegion("region1");
    region2 = createReplicatedRegion("region2");
  }

  protected void createAdditionalRegions() throws Exception {
    region3 = createReplicatedRegion("region3");
    region4 = createReplicatedRegion("region4");
  }

  protected void destroyAdditionalRegions() throws Exception {
    if (region3 != null) {
      region3.destroyRegion();
    }
    if (region4 != null) {
      region4.destroyRegion();
    }
  }

  @Test
  public void testSingleFilterWithSingleEquijoinOneToOneMapping() throws Exception {
    createRegions();

    String[] queries = new String[] {
        "<trace>select * from " + SEPARATOR + "region1 c, " + SEPARATOR
            + "region2 s where c.pkid=1 and c.pkid = s.pkid",
        "<trace>select * from " + SEPARATOR + "region1 c, " + SEPARATOR
            + "region2 s where c.pkid=1 and s.pkid = c.pkid",
        "<trace>select * from " + SEPARATOR + "region1 c, " + SEPARATOR
            + "region2 s where c.pkid = s.pkid and c.pkid=1",
        "<trace>select * from " + SEPARATOR + "region1 c, " + SEPARATOR
            + "region2 s where s.pkid = c.pkid and c.pkid=1",};

    for (int i = 0; i < 1000; i++) {
      region1.put(i, new Customer(i, i));
      region2.put(i, new Customer(i, i));
    }

    executeQueriesWithIndexCombinations(queries);
  }

  @Test
  public void testSingleFilterWithSingleEquijoinOneToOneMappingWithAdditionalJoins()
      throws Exception {
    createRegions();
    try {
      createAdditionalRegions();

      String[] queries = new String[] {
          "<trace>select * from " + SEPARATOR + "region1 c, " + SEPARATOR + "region2 s, "
              + SEPARATOR + "region3 d where c.pkid=1 and c.pkid = s.pkid and d.pkid = s.pkid", // this
          // should
          // derive
          // d
          // after
          // deriving
          // s
          // from
          // c
          "<trace>select * from " + SEPARATOR + "region1 c, " + SEPARATOR + "region2 s, "
              + SEPARATOR + "region3 d, " + SEPARATOR
              + "region4 f where c.pkid=1 and c.pkid = s.pkid and d.pkid = s.pkid and f.pkid = d.pkid", // this
          // should
          // f
          // from
          // d
          // from
          // s
          // from
          // c
          "<trace>select * from " + SEPARATOR + "region1 c, " + SEPARATOR + "region2 s, "
              + SEPARATOR + "region3 d where c.pkid=1 and c.pkid = s.pkid and d.pkid = c.pkid", // this
          // should
          // derive
          // d
          // and
          // s
          // from
          // c
          "<trace>select * from " + SEPARATOR + "region1 c, " + SEPARATOR + "region2 s, "
              + SEPARATOR + "region3 d where c.pkid=1 and c.pkid = s.pkid and s.pkid = d.pkid", // this
          // should
          // derive
          // d
          // after
          // deriving
          // s
          // from
          // c
          // (order
          // is
          // just
          // switched
          // in
          // the
          // query)
      };

      for (int i = 0; i < 30; i++) {
        region1.put(i, new Customer(i, i));
        region2.put(i, new Customer(i, i));
        region3.put(i, new Customer(i, i));
        region4.put(i, new Customer(i, i));
      }

      executeQueriesWithIndexCombinations(queries);
    } finally {
      destroyAdditionalRegions();
    }
  }


  /**
   * We do not want to test this with Primary Key on the many side or else only 1 result will be
   * returned
   */
  @Test
  public void testSingleFilterWithSingleEquijoinOneToManyMapping() throws Exception {
    createRegions();

    String[] queries =
        new String[] {"select * from " + SEPARATOR + "region1 c, " + SEPARATOR
            + "region2 s where c.pkid=1 and c.pkid = s.pkid",
            "select * from " + SEPARATOR + "region1 c, " + SEPARATOR
                + "region2 s where c.pkid=1 and s.pkid = c.pkid",
            "select * from " + SEPARATOR + "region1 c, " + SEPARATOR
                + "region2 s where c.pkid = s.pkid and c.pkid=1",
            "select * from " + SEPARATOR + "region1 c, " + SEPARATOR
                + "region2 s where s.pkid = c.pkid and c.pkid=1",
            "select distinct * from " + SEPARATOR + "region1 c, " + SEPARATOR
                + "region2 s where s.pkid = c.pkid and c.pkid=1"};

    for (int i = 0; i < 1000; i++) {
      region1.put(i, new Customer(i, i));
      region2.put(i, new Customer(i % 100, i));
    }

    executeQueriesWithIndexCombinations(queries, new DefaultIndexCreatorCallback(qs) {
      @Override
      protected String[] createIndexTypesForRegion2() {
        return new String[] {"Compact", "Hash"};
      }
    }, false);
  }

  @Test
  public void testSingleFilterWithSingleEquijoinMultipleFiltersOnSameRegionOnSameIteratorMapping()
      throws Exception {
    createRegions();

    String[] queries = new String[] {
        "select * from " + SEPARATOR + "region1 c, " + SEPARATOR
            + "region2 s where c.pkid=1 and c.pkid = s.pkid and c.id = 1",
        "select * from " + SEPARATOR + "region1 c, " + SEPARATOR
            + "region2 s where c.id = 1 and c.pkid=1 and s.pkid = c.pkid",

    };

    for (int i = 0; i < 1000; i++) {
      region1.put(i, new Customer(i, i % 10));
      region2.put(i, new Customer(i, i));
    }

    executeQueriesWithIndexCombinations(queries, new DefaultIndexCreatorCallback(qs) {
      Index secondaryIndex;

      @Override
      public void createIndexForRegion1(int indexTypeId)
          throws RegionNotFoundException, IndexExistsException, IndexNameConflictException {
        secondaryIndex = qs.createIndex("region1 id", "p.id", SEPARATOR + "region1 p");
        super.createIndexForRegion1(indexTypeId);
      }

      @Override
      public void destroyIndexForRegion1(int indexTypeId) {
        qs.removeIndex(secondaryIndex);
        super.destroyIndexForRegion1(indexTypeId);
      }

    }, false /* want to compare actual results and not size only */);
  }

  @Test
  public void testSingleFilterWithSingleEquijoinWithRangeFilters() throws Exception {
    createRegions();

    String[] queries = new String[] {
        "<trace>select * from " + SEPARATOR + "region1 c, " + SEPARATOR
            + "region2 s where c.pkid = 1 and c.id > 1 and c.id < 10 and c.pkid = s.pkid",
        "<trace>select * from " + SEPARATOR + "region1 c, " + SEPARATOR
            + "region2 s where c.pkid >= 0 and c.pkid < 10 and c.id < 10 and c.pkid = s.pkid"};

    // just need enough so that there are 1-10 ids per pkid
    for (int i = 0; i < 1000; i++) {
      region1.put(i, new Customer(i % 5, i % 10));
      region2.put(i, new Customer(i, i));
    }

    executeQueriesWithIndexCombinations(queries, new DefaultIndexCreatorCallback(qs) {
      @Override
      protected String[] createIndexTypesForRegion1() {
        return new String[] {"Compact", "Hash"};
      }
    }, false /* want to compare actual results and not size only */);
  }

  @Test
  public void testSingleFilterWithSingleEquijoinLimit() throws Exception {
    // In this test we are hoping the index being used will properly use the limit while taking into
    // consideration the filters of c.id and c.pkid
    // This test is set up so that if the pkid index is used and limit applied, if id is not taken
    // into consideration until later stages, it will lead to incorrect results (0)
    createRegions();

    String[] queries = new String[] {
        "select * from " + SEPARATOR + "region1 c, " + SEPARATOR
            + "region2 s where c.id = 3 and c.pkid > 2  and c.pkid = s.pkid limit 1",};

    for (int i = 0; i < 1000; i++) {
      region1.put(i, new Customer(i, i % 10));
      region2.put(i, new Customer(i, i));
    }

    executeQueriesWithIndexCombinations(queries, new DefaultIndexCreatorCallback(qs) {
      Index secondaryIndex;

      @Override
      public void createIndexForRegion1(int indexTypeId)
          throws RegionNotFoundException, IndexExistsException, IndexNameConflictException {
        secondaryIndex = qs.createIndex("region1 id", "p.id", SEPARATOR + "region1 p");
        super.createIndexForRegion1(indexTypeId);
      }

      @Override
      public void destroyIndexForRegion1(int indexTypeId) {
        qs.removeIndex(secondaryIndex);
        super.destroyIndexForRegion1(indexTypeId);
      }

    }, true);
  }

  @Test
  public void testSingleFilterWithSingleEquijoinNestedQuery() throws Exception {
    createRegions();

    String[] queries = new String[] {
        "select * from " + SEPARATOR + "region1 c, " + SEPARATOR
            + "region2 s where c.pkid=1 and c.pkid = s.pkid and c.pkid in (select t.pkid from "
            + SEPARATOR + "region1 t," + SEPARATOR
            + "region2 s where s.pkid=t.pkid and s.pkid = 1)",
        "select * from " + SEPARATOR + "region1 c, " + SEPARATOR
            + "region2 s where c.pkid=1 and c.pkid = s.pkid or c.pkid in set (1,2,3,4)",};

    for (int i = 0; i < 1000; i++) {
      region1.put(i, new Customer(i, i));
      region2.put(i, new Customer(i, i));
    }

    executeQueriesWithIndexCombinations(queries);
  }

  public static class Customer implements Serializable {
    public int pkid;
    public int id;
    public String name;
    public Map<String, Customer> nested = new HashMap<>();

    public Customer(int pkid, int id) {
      this.pkid = pkid;
      this.id = id;
      name = "name" + pkid;
    }

    public String toString() {
      return "Customer pkid = " + pkid + ", id: " + id + " name:" + name;
    }
  }

  private Region createReplicatedRegion(String regionName) throws ParseException {
    Cache cache = CacheUtils.getCache();
    AttributesFactory attributesFactory = new AttributesFactory();
    attributesFactory.setDataPolicy(DataPolicy.REPLICATE);
    RegionAttributes regionAttributes = attributesFactory.create();
    return cache.createRegion(regionName, regionAttributes);
  }

  protected void executeQueriesWithIndexCombinations(String[] queries)
      throws IndexExistsException, IndexNameConflictException,
      QueryInvocationTargetException, NameResolutionException, TypeMismatchException,
      FunctionDomainException {
    executeQueriesWithIndexCombinations(queries, new DefaultIndexCreatorCallback(qs), false);
  }

  protected void executeQueriesWithIndexCombinations(String[] queries,
      IndexCreatorCallback indexCreator, boolean sizeOnly)
      throws IndexExistsException, IndexNameConflictException, QueryInvocationTargetException,
      NameResolutionException, TypeMismatchException, FunctionDomainException {
    Object[] nonIndexedResults = executeQueries(queries);

    for (int r1Index = 0; r1Index < indexCreator.getNumIndexTypesForRegion1(); r1Index++) {
      indexCreator.createIndexForRegion1(r1Index);
      for (int r2Index = 0; r2Index < indexCreator.getNumIndexTypesForRegion2(); r2Index++) {
        indexCreator.createIndexForRegion2(r2Index);
        Object[] indexedResults = executeQueries(queries);
        compareResults(nonIndexedResults, indexedResults, queries, sizeOnly);
        indexCreator.destroyIndexForRegion2(r2Index);
      }
      indexCreator.destroyIndexForRegion1(r1Index);
    }
  }

  protected Object[] executeQueries(String[] queries) throws QueryInvocationTargetException,
      NameResolutionException, TypeMismatchException, FunctionDomainException {
    Object[] results = new SelectResults[queries.length];
    for (int i = 0; i < queries.length; i++) {
      results[i] = qs.newQuery(queries[i]).execute();
    }
    return results;
  }

  interface IndexCreatorCallback {
    int getNumIndexTypesForRegion1();

    int getNumIndexTypesForRegion2();

    void createIndexForRegion1(int indexTypeId)
        throws RegionNotFoundException, IndexExistsException, IndexNameConflictException;

    void createIndexForRegion2(int indexTypeId)
        throws RegionNotFoundException, IndexExistsException, IndexNameConflictException;

    void destroyIndexForRegion1(int indexTypeId);

    void destroyIndexForRegion2(int indexTypeId);
  }

  static class DefaultIndexCreatorCallback implements IndexCreatorCallback {
    protected String[] indexTypesForRegion1 = createIndexTypesForRegion1();
    protected String[] indexTypesForRegion2 = createIndexTypesForRegion2();
    protected Index indexOnR1, indexOnR2;
    protected QueryService qs;

    DefaultIndexCreatorCallback(QueryService qs) {
      this.qs = qs;
    }

    protected String[] createIndexTypesForRegion1() {
      return new String[] {"Compact", "Hash", "PrimaryKey"};
    }

    protected String[] createIndexTypesForRegion2() {
      return new String[] {"Compact", "Hash", "PrimaryKey"};
    }

    @Override
    public int getNumIndexTypesForRegion1() {
      return indexTypesForRegion1.length;
    }

    @Override
    public int getNumIndexTypesForRegion2() {
      return indexTypesForRegion2.length;
    }

    @Override
    public void createIndexForRegion1(int indexTypeId)
        throws RegionNotFoundException, IndexExistsException, IndexNameConflictException {
      indexOnR1 = createIndex(indexTypesForRegion1[indexTypeId], "region1", "pkid");

    }

    @Override
    public void createIndexForRegion2(int indexTypeId)
        throws RegionNotFoundException, IndexExistsException, IndexNameConflictException {
      indexOnR2 = createIndex(indexTypesForRegion2[indexTypeId], "region2", "pkid");
    }

    // Type id is not used here but at some future time we could store a map of indexes or find a
    // use for this id?
    @Override
    public void destroyIndexForRegion1(int indexTypeId) {
      qs.removeIndex(indexOnR1);
    }

    @Override
    public void destroyIndexForRegion2(int indexTypeId) {
      qs.removeIndex(indexOnR2);
    }


    private Index createIndex(String type, String regionName, String field)
        throws RegionNotFoundException, IndexExistsException, IndexNameConflictException {
      Index index = null;
      switch (type) {
        case "Compact":
          index = createCompactRangeIndex(regionName, field);
          break;
        case "Range":
          index = createRangeIndexOnFirstIterator(regionName, field);
          break;
        case "Hash":
          index = createHashIndex(regionName, field);
          break;
        case "PrimaryKey":
          index = createPrimaryKeyIndex(regionName, field);
          break;
      }
      return index;
    }

    private Index createCompactRangeIndex(String regionName, String fieldName)
        throws RegionNotFoundException, IndexExistsException, IndexNameConflictException {
      String fromClause = SEPARATOR + regionName + " r";
      String indexedExpression = "r." + fieldName;
      return qs.createIndex("Compact " + fromClause + ":" + indexedExpression, indexedExpression,
          fromClause);
    }

    private Index createHashIndex(String regionName, String fieldName)
        throws RegionNotFoundException, IndexExistsException, IndexNameConflictException {
      String fromClause = SEPARATOR + regionName + " r";
      String indexedExpression = "r." + fieldName;
      return qs.createHashIndex("Hash " + fromClause + ":" + indexedExpression, indexedExpression,
          fromClause);
    }

    private Index createPrimaryKeyIndex(String regionName, String fieldName)
        throws RegionNotFoundException, IndexExistsException, IndexNameConflictException {
      String fromClause = SEPARATOR + regionName + " r";
      String indexedExpression = "r." + fieldName;
      return qs.createKeyIndex("PrimaryKey " + fromClause + ":" + indexedExpression,
          indexedExpression, fromClause);
    }

    private Index createRangeIndexOnFirstIterator(String regionName, String fieldName)
        throws RegionNotFoundException, IndexExistsException, IndexNameConflictException {
      String fromClause = SEPARATOR + regionName + " r, r.nested.values v";
      String indexedExpression = "r." + fieldName;
      return qs.createIndex("Range " + fromClause + ":" + indexedExpression, indexedExpression,
          fromClause);
    }

    private Index createRangeIndexOnSecondIterator(String regionName, String fieldName)
        throws RegionNotFoundException, IndexExistsException, IndexNameConflictException {
      String fromClause = SEPARATOR + regionName + " r, r.nested.values v";
      String indexedExpression = "v." + fieldName;
      return qs.createIndex("Range " + fromClause + ":" + indexedExpression, indexedExpression,
          fromClause);
    }
  }

  private void compareResults(Object[] nonIndexedResults, Object[] indexedResults, String[] queries,
      boolean sizeOnly) {
    if (sizeOnly) {
      for (int i = 0; i < queries.length; i++) {
        assertTrue(((SelectResults) nonIndexedResults[i])
            .size() == ((SelectResults) indexedResults[i]).size());
        assertTrue(((SelectResults) nonIndexedResults[i]).size() > 0);
      }
    } else {
      StructSetOrResultsSet util = new StructSetOrResultsSet();
      for (int i = 0; i < queries.length; i++) {
        Object[][] resultsToCompare = new Object[1][2];
        resultsToCompare[0][0] = nonIndexedResults[i];
        resultsToCompare[0][1] = indexedResults[i];
        util.CompareQueryResultsWithoutAndWithIndexes(resultsToCompare, 1,
            new String[] {queries[i]});
        assertTrue(((SelectResults) nonIndexedResults[i]).size() > 0);
      }
    }
  }
}
