/*
 * 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.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;

import java.util.ArrayList;
import java.util.Set;

import javax.transaction.TransactionManager;

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

import org.apache.geode.cache.Region;
import org.apache.geode.cache.query.CacheUtils;
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.Query;
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.data.Portfolio;
import org.apache.geode.cache.query.functional.StructSetOrResultsSet;
import org.apache.geode.cache.query.internal.DefaultQuery;
import org.apache.geode.cache.query.internal.QueryExecutionContext;
import org.apache.geode.cache.query.internal.QueryObserverAdapter;
import org.apache.geode.cache.query.internal.QueryObserverHolder;
import org.apache.geode.distributed.internal.DistributionConfig;
import org.apache.geode.internal.cache.InternalCache;
import org.apache.geode.internal.cache.TXManagerImpl;
import org.apache.geode.test.junit.categories.OQLIndexTest;

@Category({OQLIndexTest.class})
public class IndexHintJUnitTest {
  private Region region;

  @Before
  public void setUp() throws Exception {
    System.setProperty(DistributionConfig.GEMFIRE_PREFIX + "Query.VERBOSE", "true");
    CacheUtils.startCache();
  }

  @After
  public void tearDown() throws Exception {
    CacheUtils.closeCache();
  }

  // tests the grammar for a hint with a single index name
  @Test
  public void testSingleIndexHint() throws Exception {
    createRegion();
    verifyQueryIndexHint();
  }

  @Test
  public void testSingleIndexHintWithTx() throws Exception {
    createRegion();
    populateData(10);
    TXManagerImpl txManager = CacheUtils.getCache().getTxManager();
    try {
      txManager.begin();
      verifyQueryIndexHint();
    } finally {
      txManager.commit();
    }
  }


  @Test
  public void testSingleIndexHintWithJTANotStarted() throws Exception {
    createRegion();
    populateData(10);
    InternalCache cache = CacheUtils.getCache();
    TransactionManager jtaManager =
        (TransactionManager) cache.getJNDIContext().lookup("java:/TransactionManager");
    try {
      jtaManager.begin();
      verifyQueryIndexHint();
    } finally {
      jtaManager.commit();
    }
  }

  @Test
  public void testSingleIndexHintWithJTAStarted() throws Exception {
    createRegion();
    populateData(1);
    InternalCache cache = CacheUtils.getCache();
    TransactionManager jtaManager =
        (TransactionManager) cache.getJNDIContext().lookup("java:/TransactionManager");
    try {
      jtaManager.begin();
      region.get("1");
      verifyQueryIndexHint();
    } finally {
      jtaManager.commit();
    }
  }

  private void verifyQueryIndexHint() throws Exception {
    QueryService qs = CacheUtils.getQueryService();
    DefaultQuery query = (DefaultQuery) qs
        .newQuery("<hint 'FirstIndex'> select * from /Portfolios p where p.ID > 10");
    QueryExecutionContext qec =
        new QueryExecutionContext(new Object[1], CacheUtils.getCache(), query);
    query.executeUsingContext(qec);

    assertTrue(qec.isHinted("FirstIndex"));
    assertEquals(-1, qec.getHintSize("FirstIndex"));
  }

  // Tests the grammar for a hint with two index names
  @Test
  public void testTwoIndexHint() throws Exception {
    createRegion();
    QueryService qs = CacheUtils.getQueryService();
    DefaultQuery query = (DefaultQuery) qs
        .newQuery("<hint 'FirstIndex', 'SecondIndex'> select * from /Portfolios p where p.ID > 10");
    QueryExecutionContext qec =
        new QueryExecutionContext(new Object[1], CacheUtils.getCache(), query);
    query.executeUsingContext(qec);

    assertTrue(qec.isHinted("FirstIndex"));
    assertTrue(qec.isHinted("SecondIndex"));
  }

  // Tests that index hints are ordered correctly
  @Test
  public void testIndexHintOrdering() throws Exception {
    createRegion();
    QueryService qs = CacheUtils.getQueryService();
    DefaultQuery query = (DefaultQuery) qs.newQuery(
        "<hint 'FirstIndex','SecondIndex','ThirdIndex','FourthIndex'>select * from /Portfolios p where p.ID > 10");
    QueryExecutionContext qec =
        new QueryExecutionContext(new Object[1], CacheUtils.getCache(), query);
    query.executeUsingContext(qec);

    assertTrue(qec.isHinted("FirstIndex"));
    assertTrue(qec.isHinted("SecondIndex"));
    assertTrue(qec.isHinted("ThirdIndex"));
    assertTrue(qec.isHinted("FourthIndex"));

    assertEquals(-4, qec.getHintSize("FirstIndex"));
    assertEquals(-3, qec.getHintSize("SecondIndex"));
    assertEquals(-2, qec.getHintSize("ThirdIndex"));
    assertEquals(-1, qec.getHintSize("FourthIndex"));
  }

  // Tests that even though we would have chosen the index anyways, that the hint still
  // allows us to chose the same index
  @Test
  public void testIndexUsageWithIndexHint() throws Exception {
    createRegion();
    populateData(1000);
    // create index
    createIndex("IDIndex", "p.ID", "/Portfolios p");
    // set observer
    QueryObserverImpl observer = new QueryObserverImpl();
    QueryObserverHolder.setInstance(observer);
    // execute query
    QueryService qs = CacheUtils.getQueryService();
    Query query = qs.newQuery("<hint 'IDIndex'>select * from /Portfolios p where p.ID > 10");
    query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("IDIndex"));
  }

  // Tests scenario where we provide a hint that is unuseable for the query
  // The other index should be used instead of the hint because the hint itself
  // would not have helped
  @Test
  public void testIndexUsageWithUnusableIndexHint() throws Exception {
    createRegion();
    populateData(1000);
    // create index
    createIndex("IDIndex", "p.ID", "/Portfolios p");
    createIndex("SecIndex", "p.secId", "/Portfolios p");
    // set observer
    QueryObserverImpl observer = new QueryObserverImpl();
    QueryObserverHolder.setInstance(observer);
    // execute query
    QueryService qs = CacheUtils.getQueryService();
    Query query = qs.newQuery("<hint 'SecIndex'>select * from /Portfolios p where p.ID > 10");
    query.execute();
    // verify index usage
    assertFalse(observer.wasIndexUsed("SecIndex"));
    assertTrue(observer.wasIndexUsed("IDIndex"));
  }


  // given a choice between two indexes, we hint on each one in different queries and verify
  // that both indexes are used
  @Test
  public void testMultiIndexWithSingleIndexHint() throws Exception {
    createRegion();
    populateData(1000);
    // create index
    createIndex("IDIndex", "p.ID", "/Portfolios p");
    createIndex("SecIndex", "p.status", "/Portfolios p");
    // set observer
    QueryObserverImpl observer = new QueryObserverImpl();
    QueryObserverHolder.setInstance(observer);
    // execute query
    SelectResults[][] results = new SelectResults[1][2];
    QueryService qs = CacheUtils.getQueryService();
    Query query = qs.newQuery(
        "<hint 'IDIndex'>select * from /Portfolios p where p.ID > 10 and p.status = 'inactive'");
    results[0][0] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("IDIndex"));
    observer.reset();

    query = qs.newQuery(
        "<hint 'SecIndex'>select * from /Portfolios p where p.ID > 10 and p.status = 'inactive'");
    results[0][1] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("SecIndex"));

    StructSetOrResultsSet ssOrrs = new StructSetOrResultsSet();
    assertEquals(495, results[0][1].size());
    // Not really with and without index but we can use this method to verify they are the same
    // results
    // regardless of which index used
    ssOrrs.CompareQueryResultsWithoutAndWithIndexes(results, 1,
        new String[] {"<query with hints>"});
  }

  // Using junction, we will hint and make sure single index hints are functioning
  @Test
  public void testMultiIndexWithSingleIndexHintWithRangeJunction() throws Exception {
    createRegion();
    populateData(1000);
    // create index
    createIndex("IDIndex", "p.ID", "/Portfolios p");
    createIndex("SecIndex", "p.status", "/Portfolios p");
    // set observer
    QueryObserverImpl observer = new QueryObserverImpl();
    QueryObserverHolder.setInstance(observer);
    // execute query
    SelectResults[][] results = new SelectResults[1][2];
    QueryService qs = CacheUtils.getQueryService();
    Query query = qs.newQuery(
        "<hint 'IDIndex'>select * from /Portfolios p where p.ID > 10 and p.ID < 200 and p.status = 'inactive'");
    results[0][0] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("IDIndex"));
    observer.reset();

    query = qs.newQuery(
        "<hint 'SecIndex'>select * from /Portfolios p where p.ID > 10 and p.ID < 200 and p.status = 'inactive'");
    results[0][1] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("SecIndex"));
    StructSetOrResultsSet ssOrrs = new StructSetOrResultsSet();

    assertEquals(95, results[0][1].size());
    // Not really with and without index but we can use this method to verify they are the same
    // results
    // regardless of which index used
    ssOrrs.CompareQueryResultsWithoutAndWithIndexes(results, 1,
        new String[] {"<query with hints>"});
  }

  // Using junction, we will hint and make sure multi index hints are functioning
  @Test
  public void testMultiIndexWithMultiIndexHint() throws Exception {
    createRegion();
    populateData(1000);
    // create index
    createIndex("IDIndex", "p.ID", "/Portfolios p");
    createIndex("SecIndex", "p.status", "/Portfolios p");
    // set observer
    QueryObserverImpl observer = new QueryObserverImpl();
    QueryObserverHolder.setInstance(observer);
    // execute query
    SelectResults[][] results = new SelectResults[1][2];
    QueryService qs = CacheUtils.getQueryService();
    Query query = qs.newQuery(
        "<hint 'IDIndex', 'SecIndex'>select * from /Portfolios p where p.ID > 10 and p.ID < 200 and p.status = 'inactive'");
    results[0][0] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("IDIndex"));
    assertTrue(observer.wasIndexUsed("SecIndex"));
    observer.reset();

    query = qs.newQuery(
        "<hint 'IDIndex'>select * from /Portfolios p where p.ID > 10 and p.ID < 200 and p.status = 'inactive'");
    results[0][1] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("IDIndex"));
    StructSetOrResultsSet ssOrrs = new StructSetOrResultsSet();

    assertEquals(95, results[0][1].size());
    // Not really with and without index but we can use this method to verify they are the same
    // results
    // regardless of which index used
    ssOrrs.CompareQueryResultsWithoutAndWithIndexes(results, 1,
        new String[] {"<query with hints>"});
  }

  @Test
  public void testIndexWithCompiledInSet() throws Exception {
    createRegion();
    populateData(1000);
    // create index
    createIndex("IDIndex", "p.ID", "/Portfolios p");
    createIndex("SecIndex", "p.status", "/Portfolios p");
    createIndex("DescriptionIndex", "p.description", "/Portfolios p");
    // set observer
    QueryObserverImpl observer = new QueryObserverImpl();
    QueryObserverHolder.setInstance(observer);
    // execute query
    SelectResults[][] results = new SelectResults[1][2];
    QueryService qs = CacheUtils.getQueryService();
    Query query = qs.newQuery(
        "<hint 'IDIndex'>select * from /Portfolios p where p.ID > 10 and p.ID < 200 and p.status = 'inactive' and p.description IN SET ('XXXX', 'XXXY')");
    results[0][0] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("IDIndex"));
    observer.reset();

    query = qs.newQuery(
        "<hint 'SecIndex'>select * from /Portfolios p where p.ID > 10 and p.ID < 200 and p.status = 'inactive' and p.description IN SET ('XXXX', 'XXXY')");
    results[0][1] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("SecIndex"));
    observer.reset();

    // Compare results with the first two index queries
    StructSetOrResultsSet ssOrrs = new StructSetOrResultsSet();
    assertEquals(95, results[0][1].size());
    // Not really with and without index but we can use this method to verify they are the same
    // results
    // regardless of which index used
    ssOrrs.CompareQueryResultsWithoutAndWithIndexes(results, 1,
        new String[] {"<query with hints>"});

    query = qs.newQuery(
        "<hint 'DescriptionIndex'>select * from /Portfolios p where p.ID > 10 and p.ID < 200 and p.status = 'inactive' and p.description IN SET ('XXXX', 'XXXY')");
    results[0][1] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("DescriptionIndex"));

    // Compare results with the final index result
    ssOrrs.CompareQueryResultsWithoutAndWithIndexes(results, 1,
        new String[] {"<query with hints>"});
  }

  @Test
  public void testHintSingleIndexWithCompiledIn() throws Exception {
    createRegion();
    populateData(1000);
    // create index
    createIndex("IDIndex", "p.ID", "/Portfolios p");
    createIndex("SecIndex", "p.status", "/Portfolios p");
    createIndex("DescriptionIndex", "p.description", "/Portfolios p");
    // set observer
    QueryObserverImpl observer = new QueryObserverImpl();
    QueryObserverHolder.setInstance(observer);
    // execute query
    SelectResults[][] results = new SelectResults[1][2];
    QueryService qs = CacheUtils.getQueryService();
    Query query = qs.newQuery(
        "<hint 'IDIndex'>select * from /Portfolios p where p.ID > 10 and p.ID < 200 and p.status = 'inactive' and p.description IN (select p.description from /Portfolios p where p.ID > 10)");
    results[0][0] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("IDIndex"));
    observer.reset();

    query = qs.newQuery(
        "<hint 'SecIndex'>select * from /Portfolios p where p.ID > 10 and p.ID < 200 and p.status = 'inactive' and p.description IN (select p.description from /Portfolios p where p.ID > 10)");
    results[0][1] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("SecIndex"));
    observer.reset();

    // Compare results with the first two index queries
    StructSetOrResultsSet ssOrrs = new StructSetOrResultsSet();
    assertEquals(95, results[0][1].size());
    // Not really with and without index but we can use this method to verify they are the same
    // results
    // regardless of which index used
    ssOrrs.CompareQueryResultsWithoutAndWithIndexes(results, 1,
        new String[] {"<query with hints>"});

    query = qs.newQuery(
        "<hint 'DescriptionIndex'>select * from /Portfolios p where p.ID > 10 and p.ID < 200 and p.status = 'inactive' and p.description IN (select p.description from /Portfolios p where p.ID > 10) ");
    results[0][1] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("DescriptionIndex"));

    // Compare results with the final index result
    ssOrrs.CompareQueryResultsWithoutAndWithIndexes(results, 1,
        new String[] {"<query with hints>"});
  }

  @Test
  public void testHintMultiIndexWithCompiledIn() throws Exception {
    createRegion();
    populateData(1000);
    // create index
    createIndex("IDIndex", "p.ID", "/Portfolios p");
    createIndex("SecIndex", "p.status", "/Portfolios p");
    createIndex("DescriptionIndex", "p.description", "/Portfolios p");
    // set observer
    QueryObserverImpl observer = new QueryObserverImpl();
    QueryObserverHolder.setInstance(observer);
    // execute query
    SelectResults[][] results = new SelectResults[1][2];
    QueryService qs = CacheUtils.getQueryService();
    Query query = qs.newQuery(
        "select * from /Portfolios p where p.ID > 10 and p.ID < 200 and p.status = 'inactive' and p.description IN (<hint 'IDIndex', 'SecIndex', 'DescriptionIndex'>select p.description from /Portfolios p where p.ID > 10)");
    results[0][0] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("IDIndex"));
    assertTrue(observer.wasIndexUsed("SecIndex"));
    observer.reset();

    query = qs.newQuery(
        "select * from /Portfolios p where p.ID > 10 and p.ID < 200 and p.status = 'inactive' and p.description IN (select p.description from /Portfolios p where p.ID > 10)");
    results[0][1] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("SecIndex"));
    assertFalse(observer.wasIndexUsed("DescriptionIndex"));
    // We end up using IDIndex for this case.
    observer.reset();

    // Compare results with the first two index queries
    StructSetOrResultsSet ssOrrs = new StructSetOrResultsSet();
    assertEquals(95, results[0][1].size());
    // Not really with and without index but we can use this method to verify they are the same
    // results
    // regardless of which index used
    ssOrrs.CompareQueryResultsWithoutAndWithIndexes(results, 1,
        new String[] {"<query with hints>"});

    query = qs.newQuery(
        "select * from /Portfolios p where p.ID > 10 and p.ID < 200 and p.status = 'inactive' and p.description IN (<hint 'DescriptionIndex'>select p.description from /Portfolios p where p.ID > 10)");
    results[0][1] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("DescriptionIndex"));
    assertFalse(observer.wasIndexUsed("SecIndex"));
    // We end up using IDIndex for this case also

    // Compare results with the final index result
    ssOrrs.CompareQueryResultsWithoutAndWithIndexes(results, 1,
        new String[] {"<query with hints>"});
  }

  // Hints inside of a nested query will trigger index usage for that query only
  // Unusable hints should behave the same for nested queries in that they are not used if
  // unapplicable to the query
  @Test
  public void testHintNestedCompiledIn() throws Exception {
    createRegion();
    populateData(1000);
    // create index
    createIndex("IDIndex", "p.ID", "/Portfolios p");
    createIndex("SecIndex", "p.status", "/Portfolios p");
    createIndex("DescriptionIndex", "p.description", "/Portfolios p");
    // set observer
    QueryObserverImpl observer = new QueryObserverImpl();
    QueryObserverHolder.setInstance(observer);
    // execute query
    SelectResults[][] results = new SelectResults[1][2];
    QueryService qs = CacheUtils.getQueryService();
    Query query = qs.newQuery(
        "select * from /Portfolios p where p.ID > 10 and p.ID < 200 and p.status = 'inactive' and p.description IN (<hint 'IDIndex', 'SecIndex', 'DescriptionIndex'>select p.description from /Portfolios p where p.ID > 10)");
    results[0][0] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("IDIndex"));
    assertTrue(observer.wasIndexUsed("SecIndex"));
    // Because it was a hint for the inner query, it was an unuseable hint for that query
    assertFalse(observer.wasIndexUsed("DescriptionIndex"));
    observer.reset();

    // query again with no hints for a "bare" comparison
    query = qs.newQuery(
        "select * from /Portfolios p where p.ID > 10 and p.ID < 200 and p.status = 'inactive' and p.description IN (select p.description from /Portfolios p where p.ID > 10)");
    results[0][1] = (SelectResults) query.execute();
    observer.reset();

    // Compare results with the first two index queries
    StructSetOrResultsSet ssOrrs = new StructSetOrResultsSet();
    assertEquals(95, results[0][1].size());
    // Not really with and without index but we can use this method to verify they are the same
    // results
    // regardless of which index used
    ssOrrs.CompareQueryResultsWithoutAndWithIndexes(results, 1,
        new String[] {"<query with hints>"});
  }

  @Test
  public void testJoinHint() throws Exception {
    createRegion();
    Region portfolios2 = createRegion("Portfolios_2");
    populateData(1000);
    populateData(portfolios2, 1000);
    // create index
    createIndex("IDIndex", "p.ID", "/Portfolios p");
    createIndex("SecIndex", "p.status", "/Portfolios p");
    createIndex("DescriptionIndex", "p.description", "/Portfolios p");

    createIndex("IDIndexOnPortfolios2", "p.ID", "/Portfolios_2 p");

    // set observer
    QueryObserverImpl observer = new QueryObserverImpl();
    QueryObserverHolder.setInstance(observer);
    // execute query
    SelectResults[][] results = new SelectResults[1][2];
    QueryService qs = CacheUtils.getQueryService();
    Query query = qs.newQuery(
        "<hint 'SecIndex'>select * from /Portfolios p, /Portfolios_2 p2 where p.ID = p2.ID and p.status = 'inactive'");
    results[0][0] = (SelectResults) query.execute();
    // verify index usage
    assertTrue(observer.wasIndexUsed("SecIndex"));
    observer.reset();

    // query again with no hints for a "bare" comparison
    query = qs.newQuery(
        "select * from /Portfolios p, /Portfolios_2 p2 where p.ID = p2.ID and p.status = 'inactive'");
    results[0][1] = (SelectResults) query.execute();
    observer.reset();

    // Compare results with the first two index queries
    StructSetOrResultsSet ssOrrs = new StructSetOrResultsSet();
    assertEquals(500, results[0][1].size());
    // Not really with and without index but we can use this method to verify they are the same
    // results
    // regardless of which index used
    ssOrrs.CompareQueryResultsWithoutAndWithIndexes(results, 1,
        new String[] {"<query with hints>"});

  }

  class QueryObserverImpl extends QueryObserverAdapter {
    ArrayList indexesUsed = new ArrayList();

    public void beforeIndexLookup(Index index, int oper, Object key) {
      indexesUsed.add(index.getName());
    }

    public void beforeIndexLookup(Index index, int lowerBoundOperator, Object lowerBoundKey,
        int upperBoundOperator, Object upperBoundKey, Set NotEqualKeys) {
      indexesUsed.add(index.getName());
    }

    public boolean wasIndexUsed(String indexName) {
      return indexesUsed.contains(indexName);
    }

    public void reset() {
      indexesUsed.clear();
    }
  }

  private Region createRegion() {
    region = createRegion("Portfolios");
    return region;
  }

  private Region createRegion(String regionName) {
    return CacheUtils.createRegion(regionName, Portfolio.class);
  }

  private void populateData(int numObjects) {
    populateData(region, numObjects);
  }

  private void populateData(Region region, int numObjects) {
    for (int i = 0; i < numObjects; i++) {
      region.put("" + i, new Portfolio(i));
    }
  }

  private void createIndex(String indexName, String indexedExpression, String regionPath)
      throws RegionNotFoundException, IndexExistsException, IndexNameConflictException {
    CacheUtils.getQueryService().createIndex(indexName, indexedExpression, regionPath);
  }
}
