/*
 * 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.solr.search.facet;

import java.lang.invoke.MethodHandles;
import java.lang.reflect.Array;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.solr.client.solrj.SolrQuery;
import org.apache.solr.client.solrj.impl.CloudSolrClient;
import org.apache.solr.client.solrj.request.CollectionAdminRequest;
import org.apache.solr.client.solrj.request.UpdateRequest;
import org.apache.solr.client.solrj.response.QueryResponse;
import org.apache.solr.cloud.SolrCloudTestCase;
import org.apache.solr.common.SolrInputDocument;
import org.apache.solr.common.params.CoreAdminParams;
import org.apache.solr.common.params.FacetParams.FacetRangeOther;
import org.apache.solr.common.util.IOUtils;
import org.apache.solr.common.util.NamedList;
import org.junit.AfterClass;
import org.junit.BeforeClass;

/**
 * Builds a random index of a few simple fields, maintaining an in-memory model of the expected doc
 * counts so that we can verify the results of range facets w/ nested field facets that need
 * refinement.
 *
 * <p>The focus here is on stressing the cases where the document values fall direct only on the
 * range boundaries, and how the various "include" options affects refinement.
 */
public class RangeFacetCloudTest extends SolrCloudTestCase {

  private static final String COLLECTION = MethodHandles.lookup().lookupClass().getName();
  private static final String CONF = COLLECTION + "_configSet";

  private static final String INT_FIELD = "range_i";
  private static final String STR_FIELD = "facet_s";
  private static final int NUM_RANGE_VALUES = 6;
  private static final int TERM_VALUES_RANDOMIZER = 100;

  private static final List<String> SORTS =
      Arrays.asList("count desc", "count asc", "index asc", "index desc");

  private static final List<EnumSet<FacetRangeOther>> OTHERS = buildListOfFacetRangeOtherOptions();
  private static final List<FacetRangeOther> BEFORE_AFTER_BETWEEN =
      Arrays.asList(FacetRangeOther.BEFORE, FacetRangeOther.AFTER, FacetRangeOther.BETWEEN);

  /**
   * the array indexes represent values in our numeric field, while the array values track the
   * number of docs that will have that value.
   */
  private static final int[] RANGE_MODEL = new int[NUM_RANGE_VALUES];
  /**
   * the array indexes represent values in our numeric field, while the array values track the
   * mapping from string field terms to facet counts for docs that have that numeric value
   */
  @SuppressWarnings({"unchecked"})
  private static final Map<String, Integer>[] TERM_MODEL =
      (Map<String, Integer>[]) Array.newInstance(Map.class, NUM_RANGE_VALUES);

  /** A basic client for operations at the cloud level, default collection will be set */
  private static CloudSolrClient CLOUD_CLIENT;

  @BeforeClass
  public static void setupCluster() throws Exception {
    final int numShards = TestUtil.nextInt(random(), 1, 5);
    final int numReplicas = 1;
    final int nodeCount = numShards * numReplicas;

    configureCluster(nodeCount)
        .addConfig(CONF, Paths.get(TEST_HOME(), "collection1", "conf"))
        .configure();

    assertEquals(
        0,
        (CollectionAdminRequest.createCollection(COLLECTION, CONF, numShards, numReplicas)
                .setProperties(
                    Collections.singletonMap(CoreAdminParams.CONFIG, "solrconfig-minimal.xml"))
                .process(cluster.getSolrClient()))
            .getStatus());

    CLOUD_CLIENT = cluster.basicSolrClientBuilder().withDefaultCollection(COLLECTION).build();

    final int numDocs = atLeast(1000);
    final int maxTermId = atLeast(TERM_VALUES_RANDOMIZER);

    // clear the RANGE_MODEL
    Arrays.fill(RANGE_MODEL, 0);
    // seed the TERM_MODEL Maps, so we don't have null check later
    for (int i = 0; i < NUM_RANGE_VALUES; i++) {
      TERM_MODEL[i] = new LinkedHashMap<>();
    }

    // build our index & our models
    for (int id = 0; id < numDocs; id++) {
      final int rangeVal = random().nextInt(NUM_RANGE_VALUES);
      final String termVal = "x" + random().nextInt(maxTermId);
      final SolrInputDocument doc =
          sdoc("id", "" + id, INT_FIELD, "" + rangeVal, STR_FIELD, termVal);
      RANGE_MODEL[rangeVal]++;
      TERM_MODEL[rangeVal].merge(termVal, 1, Integer::sum);

      assertEquals(0, (new UpdateRequest().add(doc)).process(CLOUD_CLIENT).getStatus());
    }
    assertEquals(0, CLOUD_CLIENT.commit().getStatus());
  }

  public void testInclude_Lower() throws Exception {
    for (boolean doSubFacet : Arrays.asList(false, true)) {
      final Integer subFacetLimit = pickSubFacetLimit(doSubFacet);
      final CharSequence subFacet = makeSubFacet(subFacetLimit);
      for (EnumSet<FacetRangeOther> other : OTHERS) {
        final String otherStr = formatFacetRangeOther(other);
        for (String include : Arrays.asList(", include:lower", "")) { // same behavior
          final SolrQuery solrQuery =
              new SolrQuery(
                  "q",
                  "*:*",
                  "rows",
                  "0",
                  "json.facet",
                  // exclude a single low value from our ranges
                  "{ foo:{ type:range, field:"
                      + INT_FIELD
                      + " start:1, end:5, gap:1"
                      + otherStr
                      + include
                      + subFacet
                      + " } }");

          final QueryResponse rsp = CLOUD_CLIENT.query(solrQuery);
          try {
            @SuppressWarnings({"unchecked"})
            final NamedList<Object> foo =
                ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
            @SuppressWarnings({"unchecked"})
            final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

            assertEquals("num buckets", 4, buckets.size());
            for (int i = 0; i < 4; i++) {
              int expectedVal = i + 1;
              assertBucket(
                  "bucket#" + i,
                  expectedVal,
                  modelVals(expectedVal),
                  subFacetLimit,
                  buckets.get(i));
            }

            assertBeforeAfterBetween(
                other, modelVals(0), modelVals(5), modelVals(1, 4), subFacetLimit, foo);

          } catch (AssertionError | RuntimeException ae) {
            throw new AssertionError(solrQuery + " -> " + rsp + " ===> " + ae.getMessage(), ae);
          }
        }
      }
    }
  }

  public void testInclude_Lower_Gap2() throws Exception {
    for (boolean doSubFacet : Arrays.asList(false, true)) {
      final Integer subFacetLimit = pickSubFacetLimit(doSubFacet);
      final CharSequence subFacet = makeSubFacet(subFacetLimit);
      for (EnumSet<FacetRangeOther> other : OTHERS) {
        final String otherStr = formatFacetRangeOther(other);
        for (String include : Arrays.asList(", include:lower", "")) { // same behavior
          final SolrQuery solrQuery =
              new SolrQuery(
                  "q",
                  "*:*",
                  "rows",
                  "0",
                  "json.facet",
                  "{ foo:{ type:range, field:"
                      + INT_FIELD
                      + " start:0, end:5, gap:2"
                      + otherStr
                      + include
                      + subFacet
                      + " } }");

          final QueryResponse rsp = CLOUD_CLIENT.query(solrQuery);
          try {
            @SuppressWarnings({"unchecked"})
            final NamedList<Object> foo =
                ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
            @SuppressWarnings({"unchecked"})
            final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

            assertEquals("num buckets", 3, buckets.size());
            assertBucket("bucket#0", 0, modelVals(0, 1), subFacetLimit, buckets.get(0));
            assertBucket("bucket#1", 2, modelVals(2, 3), subFacetLimit, buckets.get(1));
            assertBucket("bucket#2", 4, modelVals(4, 5), subFacetLimit, buckets.get(2));

            assertBeforeAfterBetween(
                other, emptyVals(), emptyVals(), modelVals(0, 5), subFacetLimit, foo);

          } catch (AssertionError | RuntimeException ae) {
            throw new AssertionError(solrQuery + " -> " + rsp + " ===> " + ae.getMessage(), ae);
          }
        }
      }
    }
  }

  public void testInclude_Lower_Gap2_hardend() throws Exception {
    for (boolean doSubFacet : Arrays.asList(false, true)) {
      final Integer subFacetLimit = pickSubFacetLimit(doSubFacet);
      final CharSequence subFacet = makeSubFacet(subFacetLimit);
      for (EnumSet<FacetRangeOther> other : OTHERS) {
        final String otherStr = formatFacetRangeOther(other);
        for (String include : Arrays.asList(", include:lower", "")) { // same behavior
          final SolrQuery solrQuery =
              new SolrQuery(
                  "q",
                  "*:*",
                  "rows",
                  "0",
                  "json.facet",
                  "{ foo:{ type:range, field:"
                      + INT_FIELD
                      + " start:0, end:5, gap:2, hardend:true"
                      + otherStr
                      + include
                      + subFacet
                      + " } }");

          final QueryResponse rsp = CLOUD_CLIENT.query(solrQuery);
          try {
            @SuppressWarnings({"unchecked"})
            final NamedList<Object> foo =
                ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
            @SuppressWarnings({"unchecked"})
            final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

            assertEquals("num buckets", 3, buckets.size());
            assertBucket("bucket#0", 0, modelVals(0, 1), subFacetLimit, buckets.get(0));
            assertBucket("bucket#1", 2, modelVals(2, 3), subFacetLimit, buckets.get(1));
            assertBucket("bucket#2", 4, modelVals(4), subFacetLimit, buckets.get(2));

            assertBeforeAfterBetween(
                other, emptyVals(), modelVals(5), modelVals(0, 4), subFacetLimit, foo);

          } catch (AssertionError | RuntimeException ae) {
            throw new AssertionError(solrQuery + " -> " + rsp + " ===> " + ae.getMessage(), ae);
          }
        }
      }
    }
  }

  public void testStatsWithOmitHeader() throws Exception {
    // SOLR-13509: no NPE should be thrown when only stats are specified with omitHeader=true
    SolrQuery solrQuery =
        new SolrQuery(
            "q",
            "*:*",
            "omitHeader",
            "true",
            "json.facet",
            "{unique_foo:\"unique(" + STR_FIELD + ")\"}");
    final QueryResponse rsp = CLOUD_CLIENT.query(solrQuery);
    // response shouldn't contain header as omitHeader is set to true
    assertNull(rsp.getResponseHeader());
  }

  public void testInclude_Upper() throws Exception {
    for (boolean doSubFacet : Arrays.asList(false, true)) {
      final Integer subFacetLimit = pickSubFacetLimit(doSubFacet);
      final CharSequence subFacet = makeSubFacet(subFacetLimit);
      for (EnumSet<FacetRangeOther> other : OTHERS) {
        final String otherStr = formatFacetRangeOther(other);
        final SolrQuery solrQuery =
            new SolrQuery(
                "q",
                "*:*",
                "rows",
                "0",
                "json.facet",
                // exclude a single high value from our ranges
                "{ foo:{ type:range, field:"
                    + INT_FIELD
                    + " start:0, end:4, gap:1, include:upper"
                    + otherStr
                    + subFacet
                    + " } }");

        final QueryResponse rsp = CLOUD_CLIENT.query(solrQuery);
        try {
          @SuppressWarnings({"unchecked"})
          final NamedList<Object> foo =
              ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
          @SuppressWarnings({"unchecked"})
          final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

          assertEquals("num buckets", 4, buckets.size());
          for (int i = 0; i < 4; i++) {
            assertBucket("bucket#" + i, i, modelVals(i + 1), subFacetLimit, buckets.get(i));
          }
          assertBeforeAfterBetween(
              other, modelVals(0), modelVals(5), modelVals(1, 4), subFacetLimit, foo);

        } catch (AssertionError | RuntimeException ae) {
          throw new AssertionError(solrQuery + " -> " + rsp + " ===> " + ae.getMessage(), ae);
        }
      }
    }
  }

  public void testInclude_Upper_Gap2() throws Exception {
    for (boolean doSubFacet : Arrays.asList(false, true)) {
      final Integer subFacetLimit = pickSubFacetLimit(doSubFacet);
      final CharSequence subFacet = makeSubFacet(subFacetLimit);
      for (EnumSet<FacetRangeOther> other : OTHERS) {
        final String otherStr = formatFacetRangeOther(other);
        final SolrQuery solrQuery =
            new SolrQuery(
                "q",
                "*:*",
                "rows",
                "0",
                "json.facet",
                // exclude a single high value from our ranges
                "{ foo:{ type:range, field:"
                    + INT_FIELD
                    + " start:0, end:4, gap:2, include:upper"
                    + otherStr
                    + subFacet
                    + " } }");

        final QueryResponse rsp = CLOUD_CLIENT.query(solrQuery);
        try {
          @SuppressWarnings({"unchecked"})
          final NamedList<Object> foo =
              ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
          @SuppressWarnings({"unchecked"})
          final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

          assertEquals("num buckets", 2, buckets.size());
          assertBucket("bucket#0", 0, modelVals(1, 2), subFacetLimit, buckets.get(0));
          assertBucket("bucket#1", 2, modelVals(3, 4), subFacetLimit, buckets.get(1));

          assertBeforeAfterBetween(
              other, modelVals(0), modelVals(5), modelVals(1, 4), subFacetLimit, foo);

        } catch (AssertionError | RuntimeException ae) {
          throw new AssertionError(
              solrQuery + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
        }
      }
    }
  }

  public void testInclude_Edge() throws Exception {
    for (boolean doSubFacet : Arrays.asList(false, true)) {
      final Integer subFacetLimit = pickSubFacetLimit(doSubFacet);
      final CharSequence subFacet = makeSubFacet(subFacetLimit);
      for (EnumSet<FacetRangeOther> other : OTHERS) {
        final String otherStr = formatFacetRangeOther(other);
        final SolrQuery solrQuery =
            new SolrQuery(
                "q",
                "*:*",
                "rows",
                "0",
                "json.facet",
                // exclude a single low/high value from our ranges
                "{ foo:{ type:range, field:"
                    + INT_FIELD
                    + " start:1, end:4, gap:1, include:edge"
                    + otherStr
                    + subFacet
                    + " } }");

        final QueryResponse rsp = CLOUD_CLIENT.query(solrQuery);
        try {
          @SuppressWarnings({"unchecked"})
          final NamedList<Object> foo =
              ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
          @SuppressWarnings({"unchecked"})
          final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

          assertEquals("num buckets", 3, buckets.size());

          assertBucket("bucket#0", 1, modelVals(1), subFacetLimit, buckets.get(0));

          // middle bucket doesn't include lower or upper, so it's empty
          assertBucket("bucket#1", 2, emptyVals(), subFacetLimit, buckets.get(1));

          assertBucket("bucket#2", 3, modelVals(4), subFacetLimit, buckets.get(2));

          assertBeforeAfterBetween(
              other, modelVals(0), modelVals(5), modelVals(1, 4), subFacetLimit, foo);

        } catch (AssertionError | RuntimeException ae) {
          throw new AssertionError(
              solrQuery + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
        }
      }
    }
  }

  public void testInclude_EdgeLower() throws Exception {
    for (boolean doSubFacet : Arrays.asList(false, true)) {
      final Integer subFacetLimit = pickSubFacetLimit(doSubFacet);
      final CharSequence subFacet = makeSubFacet(subFacetLimit);
      for (EnumSet<FacetRangeOther> other : OTHERS) {
        final String otherStr = formatFacetRangeOther(other);
        for (String include :
            Arrays.asList(", include:'edge,lower'", ", include:[edge,lower]")) { // same
          final SolrQuery solrQuery =
              new SolrQuery(
                  "q",
                  "*:*",
                  "rows",
                  "0",
                  "json.facet",
                  // exclude a single low/high value from our ranges
                  "{ foo:{ type:range, field:"
                      + INT_FIELD
                      + " start:1, end:4, gap:1"
                      + otherStr
                      + include
                      + subFacet
                      + " } }");

          final QueryResponse rsp = CLOUD_CLIENT.query(solrQuery);
          try {
            @SuppressWarnings({"unchecked"})
            final NamedList<Object> foo =
                ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
            @SuppressWarnings({"unchecked"})
            final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

            assertEquals("num buckets", 3, buckets.size());

            assertBucket("bucket#0", 1, modelVals(1), subFacetLimit, buckets.get(0));
            assertBucket("bucket#1", 2, modelVals(2), subFacetLimit, buckets.get(1));
            assertBucket("bucket#2", 3, modelVals(3, 4), subFacetLimit, buckets.get(2));

            assertBeforeAfterBetween(
                other, modelVals(0), modelVals(5), modelVals(1, 4), subFacetLimit, foo);

          } catch (AssertionError | RuntimeException ae) {
            throw new AssertionError(
                solrQuery + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
          }
        }
      }
    }
  }

  public void testInclude_EdgeUpper() throws Exception {
    for (boolean doSubFacet : Arrays.asList(false, true)) {
      final Integer subFacetLimit = pickSubFacetLimit(doSubFacet);
      final CharSequence subFacet = makeSubFacet(subFacetLimit);
      for (EnumSet<FacetRangeOther> other : OTHERS) {
        final String otherStr = formatFacetRangeOther(other);
        for (String include :
            Arrays.asList(", include:'edge,upper'", ", include:[edge,upper]")) { // same
          final SolrQuery solrQuery =
              new SolrQuery(
                  "q",
                  "*:*",
                  "rows",
                  "0",
                  "json.facet",
                  // exclude a single low/high value from our ranges
                  "{ foo:{ type:range, field:"
                      + INT_FIELD
                      + " start:1, end:4, gap:1"
                      + otherStr
                      + include
                      + subFacet
                      + " } }");

          final QueryResponse rsp = CLOUD_CLIENT.query(solrQuery);
          try {
            @SuppressWarnings({"unchecked"})
            final NamedList<Object> foo =
                ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
            @SuppressWarnings({"unchecked"})
            final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

            assertEquals("num buckets", 3, buckets.size());

            assertBucket("bucket#0", 1, modelVals(1, 2), subFacetLimit, buckets.get(0));
            assertBucket("bucket#1", 2, modelVals(3), subFacetLimit, buckets.get(1));
            assertBucket("bucket#2", 3, modelVals(4), subFacetLimit, buckets.get(2));

            assertBeforeAfterBetween(
                other, modelVals(0), modelVals(5), modelVals(1, 4), subFacetLimit, foo);

          } catch (AssertionError | RuntimeException ae) {
            throw new AssertionError(
                solrQuery + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
          }
        }
      }
    }
  }

  public void testInclude_EdgeLowerUpper() throws Exception {
    for (boolean doSubFacet : Arrays.asList(false, true)) {
      final Integer subFacetLimit = pickSubFacetLimit(doSubFacet);
      final CharSequence subFacet = makeSubFacet(subFacetLimit);
      for (EnumSet<FacetRangeOther> other : OTHERS) {
        final String otherStr = formatFacetRangeOther(other);
        for (String include :
            Arrays.asList(", include:'edge,lower,upper'", ", include:[edge,lower,upper]")) { // same
          final SolrQuery solrQuery =
              new SolrQuery(
                  "q",
                  "*:*",
                  "rows",
                  "0",
                  "json.facet",
                  // exclude a single low/high value from our ranges
                  "{ foo:{ type:range, field:"
                      + INT_FIELD
                      + " start:1, end:4, gap:1"
                      + otherStr
                      + include
                      + subFacet
                      + " } }");

          final QueryResponse rsp = CLOUD_CLIENT.query(solrQuery);
          try {
            @SuppressWarnings({"unchecked"})
            final NamedList<Object> foo =
                ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
            @SuppressWarnings({"unchecked"})
            final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

            assertEquals("num buckets", 3, buckets.size());

            assertBucket("bucket#0", 1, modelVals(1, 2), subFacetLimit, buckets.get(0));
            assertBucket("bucket#1", 2, modelVals(2, 3), subFacetLimit, buckets.get(1));
            assertBucket("bucket#2", 3, modelVals(3, 4), subFacetLimit, buckets.get(2));

            assertBeforeAfterBetween(
                other, modelVals(0), modelVals(5), modelVals(1, 4), subFacetLimit, foo);

          } catch (AssertionError | RuntimeException ae) {
            throw new AssertionError(
                solrQuery + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
          }
        }
      }
    }
  }

  public void testInclude_All() throws Exception {
    for (boolean doSubFacet : Arrays.asList(false, true)) {
      final Integer subFacetLimit = pickSubFacetLimit(doSubFacet);
      final CharSequence subFacet = makeSubFacet(subFacetLimit);
      for (EnumSet<FacetRangeOther> other : OTHERS) {
        final String otherStr = formatFacetRangeOther(other);
        for (String include :
            Arrays.asList(
                ", include:'edge,lower,upper,outer'",
                ", include:[edge,lower,upper,outer]",
                ", include:all")) { // same
          final SolrQuery solrQuery =
              new SolrQuery(
                  "q",
                  "*:*",
                  "rows",
                  "0",
                  "json.facet",
                  // exclude a single low/high value from our ranges
                  "{ foo:{ type:range, field:"
                      + INT_FIELD
                      + " start:1, end:4, gap:1"
                      + otherStr
                      + include
                      + subFacet
                      + " } }");

          final QueryResponse rsp = CLOUD_CLIENT.query(solrQuery);
          try {
            @SuppressWarnings({"unchecked"})
            final NamedList<Object> foo =
                ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
            @SuppressWarnings({"unchecked"})
            final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

            assertEquals("num buckets", 3, buckets.size());

            assertBucket("bucket#0", 1, modelVals(1, 2), subFacetLimit, buckets.get(0));
            assertBucket("bucket#1", 2, modelVals(2, 3), subFacetLimit, buckets.get(1));
            assertBucket("bucket#2", 3, modelVals(3, 4), subFacetLimit, buckets.get(2));

            assertBeforeAfterBetween(
                other, modelVals(0, 1), modelVals(4, 5), modelVals(1, 4), subFacetLimit, foo);

          } catch (AssertionError | RuntimeException ae) {
            throw new AssertionError(
                solrQuery + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
          }
        }
      }
    }
  }

  /** This test will also sanity check that mincount is working properly */
  public void testInclude_All_Gap2() throws Exception {
    for (boolean doSubFacet : Arrays.asList(false, true)) {
      final Integer subFacetLimit = pickSubFacetLimit(doSubFacet);
      final CharSequence subFacet = makeSubFacet(subFacetLimit);
      for (EnumSet<FacetRangeOther> other : OTHERS) {
        final String otherStr = formatFacetRangeOther(other);
        for (String include :
            Arrays.asList(
                ", include:'edge,lower,upper,outer'",
                ", include:[edge,lower,upper,outer]",
                ", include:all")) { // same

          // we also want to sanity check that mincount doesn't bork anything,
          // so we're going to do the query twice:
          // 1) no mincount, keep track of which bucket has the highest count & what it was
          // 2) use that value as the mincount, assert that the other bucket isn't returned
          long mincount_to_use = -1;
          Object expected_mincount_bucket_val =
              null; // HACK: use null to mean neither in case of tie

          // initial query, no mincount...
          SolrQuery solrQuery =
              new SolrQuery(
                  "q",
                  "*:*",
                  "rows",
                  "0",
                  "json.facet",
                  // exclude a single low/high value from our ranges
                  "{ foo:{ type:range, field:"
                      + INT_FIELD
                      + ", start:1, end:4, gap:2"
                      + otherStr
                      + include
                      + subFacet
                      + " } }");

          QueryResponse rsp = CLOUD_CLIENT.query(solrQuery);
          try {
            @SuppressWarnings({"unchecked"})
            final NamedList<Object> foo =
                ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
            @SuppressWarnings({"unchecked"})
            final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

            assertEquals("num buckets", 2, buckets.size());

            assertBucket("bucket#0", 1, modelVals(1, 3), subFacetLimit, buckets.get(0));
            assertBucket("bucket#1", 3, modelVals(3, 5), subFacetLimit, buckets.get(1));

            assertBeforeAfterBetween(
                other, modelVals(0, 1), modelVals(5), modelVals(1, 5), subFacetLimit, foo);

            // if we've made it this far, then our buckets match the model
            // now use our buckets to pick a mincount to use based on the MIN(+1) count seen
            long count0 = ((Number) buckets.get(0).get("count")).longValue();
            long count1 = ((Number) buckets.get(1).get("count")).longValue();

            mincount_to_use = 1 + Math.min(count0, count1);
            if (count0 > count1) {
              expected_mincount_bucket_val = buckets.get(0).get("val");
            } else if (count1 > count0) {
              expected_mincount_bucket_val = buckets.get(1).get("val");
            }

          } catch (AssertionError | RuntimeException ae) {
            throw new AssertionError(
                solrQuery + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
          }

          // second query, using mincount...
          solrQuery =
              new SolrQuery(
                  "q",
                  "*:*",
                  "rows",
                  "0",
                  "json.facet",
                  // exclude a single low/high value from our ranges,
                  "{ foo:{ type:range, field:"
                      + INT_FIELD
                      + ", mincount:"
                      + mincount_to_use
                      + ", start:1, end:4, gap:2"
                      + otherStr
                      + include
                      + subFacet
                      + " } }");

          rsp = CLOUD_CLIENT.query(solrQuery);
          try {
            @SuppressWarnings({"unchecked"})
            final NamedList<Object> foo =
                ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
            @SuppressWarnings({"unchecked"})
            final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

            if (null == expected_mincount_bucket_val) {
              assertEquals("num buckets", 0, buckets.size());
            } else {
              assertEquals("num buckets", 1, buckets.size());
              final Object actualBucket = buckets.get(0);
              if (expected_mincount_bucket_val.equals(1)) {
                assertBucket("bucket#0(0)", 1, modelVals(1, 3), subFacetLimit, actualBucket);
              } else {
                assertBucket("bucket#0(1)", 3, modelVals(3, 5), subFacetLimit, actualBucket);
              }
            }

            // regardless of mincount, the before/after/between special buckets should always be
            // returned
            assertBeforeAfterBetween(
                other, modelVals(0, 1), modelVals(5), modelVals(1, 5), subFacetLimit, foo);

          } catch (AssertionError | RuntimeException ae) {
            throw new AssertionError(
                solrQuery + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
          }
        }
      }
    }
  }

  public void testInclude_All_Gap2_hardend() throws Exception {
    for (boolean doSubFacet : Arrays.asList(false, true)) {
      final Integer subFacetLimit = pickSubFacetLimit(doSubFacet);
      final CharSequence subFacet = makeSubFacet(subFacetLimit);
      for (EnumSet<FacetRangeOther> other : OTHERS) {
        final String otherStr = formatFacetRangeOther(other);
        for (String include :
            Arrays.asList(
                ", include:'edge,lower,upper,outer'",
                ", include:[edge,lower,upper,outer]",
                ", include:all")) { // same
          final SolrQuery solrQuery =
              new SolrQuery(
                  "q",
                  "*:*",
                  "rows",
                  "0",
                  "json.facet",
                  // exclude a single low/high value from our ranges
                  "{ foo:{ type:range, field:"
                      + INT_FIELD
                      + " start:1, end:4, gap:2, hardend:true"
                      + otherStr
                      + include
                      + subFacet
                      + " } }");

          final QueryResponse rsp = CLOUD_CLIENT.query(solrQuery);
          try {
            @SuppressWarnings({"unchecked"})
            final NamedList<Object> foo =
                ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
            @SuppressWarnings({"unchecked"})
            final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

            assertEquals("num buckets", 2, buckets.size());

            assertBucket("bucket#0", 1, modelVals(1, 3), subFacetLimit, buckets.get(0));
            assertBucket("bucket#1", 3, modelVals(3, 4), subFacetLimit, buckets.get(1));

            assertBeforeAfterBetween(
                other, modelVals(0, 1), modelVals(4, 5), modelVals(1, 4), subFacetLimit, foo);

          } catch (AssertionError | RuntimeException ae) {
            throw new AssertionError(
                solrQuery + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
          }
        }
      }
    }
  }

  public void testRangeWithInterval() throws Exception {
    for (boolean doSubFacet : Arrays.asList(false, true)) {
      final Integer subFacetLimit = pickSubFacetLimit(doSubFacet);
      final CharSequence subFacet = makeSubFacet(subFacetLimit);
      for (boolean incUpper : Arrays.asList(false, true)) {
        String incUpperStr = ",inclusive_to:" + incUpper;
        final SolrQuery solrQuery =
            new SolrQuery(
                "q",
                "*:*",
                "rows",
                "0",
                "json.facet",
                "{ foo:{ type:range, field:"
                    + INT_FIELD
                    + " ranges:[{from:1, to:2"
                    + incUpperStr
                    + "},"
                    + "{from:2, to:3"
                    + incUpperStr
                    + "},{from:3, to:4"
                    + incUpperStr
                    + "},{from:4, to:5"
                    + incUpperStr
                    + "}]"
                    + subFacet
                    + " } }");

        final QueryResponse rsp = CLOUD_CLIENT.query(solrQuery);
        try {
          @SuppressWarnings({"unchecked"})
          final NamedList<Object> foo =
              ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
          @SuppressWarnings({"unchecked"})
          final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

          assertEquals("num buckets", 4, buckets.size());
          for (int i = 0; i < 4; i++) {
            String expectedVal = "[" + (i + 1) + "," + (i + 2) + (incUpper ? "]" : ")");
            ModelRange modelVals = incUpper ? modelVals(i + 1, i + 2) : modelVals(i + 1);
            assertBucket("bucket#" + i, expectedVal, modelVals, subFacetLimit, buckets.get(i));
          }
        } catch (AssertionError | RuntimeException ae) {
          throw new AssertionError(
              solrQuery + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
        }
      }
    }
  }

  public void testRangeWithOldIntervalFormat() throws Exception {
    for (boolean doSubFacet : Arrays.asList(false, true)) {
      final Integer subFacetLimit = pickSubFacetLimit(doSubFacet);
      final CharSequence subFacet = makeSubFacet(subFacetLimit);
      for (boolean incUpper : Arrays.asList(false, true)) {
        String incUpperStr = incUpper ? "]\"" : ")\"";
        final SolrQuery solrQuery =
            new SolrQuery(
                "q",
                "*:*",
                "rows",
                "0",
                "json.facet",
                "{ foo:{ type:range, field:"
                    + INT_FIELD
                    + " ranges:[{range:\"[1,2"
                    + incUpperStr
                    + "},"
                    + "{range:\"[2,3"
                    + incUpperStr
                    + "},{range:\"[3,4"
                    + incUpperStr
                    + "},{range:\"[4,5"
                    + incUpperStr
                    + "}]"
                    + subFacet
                    + " } }");

        final QueryResponse rsp = CLOUD_CLIENT.query(solrQuery);
        try {
          @SuppressWarnings({"unchecked"})
          final NamedList<Object> foo =
              ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
          @SuppressWarnings({"unchecked"})
          final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

          assertEquals("num buckets", 4, buckets.size());
          for (int i = 0; i < 4; i++) {
            String expectedVal = "[" + (i + 1) + "," + (i + 2) + (incUpper ? "]" : ")");
            ModelRange modelVals = incUpper ? modelVals(i + 1, i + 2) : modelVals(i + 1);
            assertBucket("bucket#" + i, expectedVal, modelVals, subFacetLimit, buckets.get(i));
          }
        } catch (AssertionError | RuntimeException ae) {
          throw new AssertionError(
              solrQuery + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
        }
      }
    }
  }

  public void testIntervalWithMincount() throws Exception {
    for (boolean doSubFacet : Arrays.asList(false, true)) {
      final Integer subFacetLimit = pickSubFacetLimit(doSubFacet);
      final CharSequence subFacet = makeSubFacet(subFacetLimit);

      long mincount_to_use = -1;
      Object expected_mincount_bucket_val = null;

      // without mincount
      SolrQuery solrQuery =
          new SolrQuery(
              "q",
              "*:*",
              "rows",
              "0",
              "json.facet",
              "{ foo:{ type:range, field:"
                  + INT_FIELD
                  + " ranges:[{from:1, to:3},{from:3, to:5}]"
                  + subFacet
                  + " } }");

      QueryResponse rsp = CLOUD_CLIENT.query(solrQuery);
      try {
        @SuppressWarnings({"unchecked"})
        final NamedList<Object> foo =
            ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
        @SuppressWarnings({"unchecked"})
        final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

        assertEquals("num buckets", 2, buckets.size());

        // upper is not included
        assertBucket("bucket#0", "[1,3)", modelVals(1, 2), subFacetLimit, buckets.get(0));
        assertBucket("bucket#1", "[3,5)", modelVals(3, 4), subFacetLimit, buckets.get(1));

        // if we've made it this far, then our buckets match the model
        // now use our buckets to pick a mincount to use based on the MIN(+1) count seen
        long count0 = ((Number) buckets.get(0).get("count")).longValue();
        long count1 = ((Number) buckets.get(1).get("count")).longValue();

        mincount_to_use = 1 + Math.min(count0, count1);
        if (count0 > count1) {
          expected_mincount_bucket_val = buckets.get(0).get("val");
        } else if (count1 > count0) {
          expected_mincount_bucket_val = buckets.get(1).get("val");
        }

      } catch (AssertionError | RuntimeException ae) {
        throw new AssertionError(
            solrQuery + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
      }

      // with mincount
      solrQuery =
          new SolrQuery(
              "q",
              "*:*",
              "rows",
              "0",
              "json.facet",
              "{ foo:{ type:range, field:"
                  + INT_FIELD
                  + " ranges:[{from:1, to:3},{from:3, to:5}]"
                  + ",mincount:"
                  + mincount_to_use
                  + subFacet
                  + " } }");

      rsp = CLOUD_CLIENT.query(solrQuery);
      try {
        @SuppressWarnings({"unchecked"})
        final NamedList<Object> foo =
            ((NamedList<NamedList<Object>>) rsp.getResponse().get("facets")).get("foo");
        @SuppressWarnings({"unchecked"})
        final List<NamedList<Object>> buckets = (List<NamedList<Object>>) foo.get("buckets");

        if (null == expected_mincount_bucket_val) {
          assertEquals("num buckets", 0, buckets.size());
        } else {
          assertEquals("num buckets", 1, buckets.size());
          final Object actualBucket = buckets.get(0);
          if (expected_mincount_bucket_val.equals("[1,3)")) {
            assertBucket("bucket#0(0)", "[1,3)", modelVals(1, 2), subFacetLimit, actualBucket);
          } else {
            assertBucket("bucket#0(1)", "[3,5)", modelVals(3, 4), subFacetLimit, actualBucket);
          }
        }
      } catch (AssertionError | RuntimeException ae) {
        throw new AssertionError(
            solrQuery + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
      }
    }
  }

  /**
   * Helper method for validating a single 'bucket' from a Range facet.
   *
   * @param label to use in assertions
   * @param expectedVal <code>"val"</code> to assert for this bucket, use <code>null</code> for
   *     special "buckets" like before, after, between.
   * @param expectedRangeValues a range of the expected values in the numeric field whose cumulative
   *     counts should match this buckets <code>"count"</code>
   * @param subFacetLimitUsed if null, then assert this bucket has no <code>"bar"</code> subfacet,
   *     otherwise assert expected term counts for each actual term, and sanity check the number
   *     terms returnd against the model and/or this limit.
   * @param actualBucket the actual bucket returned from a query for all assertions to be conducted
   *     against.
   */
  private static void assertBucket(
      final String label,
      final Object expectedVal,
      final ModelRange expectedRangeValues,
      final Integer subFacetLimitUsed,
      final Object actualBucket) {
    try {

      assertNotNull("null bucket", actualBucket);
      assertNotNull("expectedRangeValues", expectedRangeValues);
      assertTrue("bucket is not a NamedList", actualBucket instanceof NamedList);
      @SuppressWarnings({"unchecked"})
      final NamedList<Object> bucket = (NamedList<Object>) actualBucket;

      if (null != expectedVal) {
        assertEquals("val", expectedVal, bucket.get("val"));
      }

      // figure out the model from our range of values...
      long expectedCount = 0;
      List<Map<String, Integer>> toMerge = new ArrayList<>(NUM_RANGE_VALUES);
      for (int i = expectedRangeValues.lower; i <= expectedRangeValues.upper; i++) {
        expectedCount += RANGE_MODEL[i];
        toMerge.add(TERM_MODEL[i]);
      }

      assertEquals("count", expectedCount, bucket.get("count"));

      // merge the maps of our range values by summing the (int) values on key collisions
      final Map<String, Long> expectedTermCounts =
          toMerge.stream()
              .flatMap(m -> m.entrySet().stream())
              .collect(Collectors.toMap(Entry::getKey, (e -> e.getValue().longValue()), Long::sum));

      if (null == subFacetLimitUsed || 0 == expectedCount) {
        assertNull("unexpected subfacets", bucket.get("bar"));
      } else {
        @SuppressWarnings({"unchecked"})
        NamedList<Object> bar = ((NamedList<Object>) bucket.get("bar"));
        assertNotNull("can't find subfacet 'bar'", bar);

        final int numBucketsExpected =
            subFacetLimitUsed < 0
                ? expectedTermCounts.size()
                : Math.min(subFacetLimitUsed, expectedTermCounts.size());
        @SuppressWarnings({"unchecked"})
        final List<NamedList<Object>> subBuckets = (List<NamedList<Object>>) bar.get("buckets");
        // we should either have filled out the expected limit, or
        assertEquals("num subfacet buckets", numBucketsExpected, subBuckets.size());

        // assert sub-facet term counts for the subBuckets that do exist
        for (NamedList<Object> subBucket : subBuckets) {
          final Object term = subBucket.get("val");
          assertNotNull("subfacet bucket with null term: " + subBucket, term);
          final Long expectedTermCount = expectedTermCounts.get(term.toString());
          assertNotNull("unexpected subfacet bucket: " + subBucket, expectedTermCount);
          assertEquals(
              "subfacet count for term: " + term, expectedTermCount, subBucket.get("count"));
        }
      }

    } catch (AssertionError | RuntimeException ae) {
      throw new AssertionError(label + ": " + ae.getMessage(), ae);
    }
  }

  /**
   * A convenience method for calling {@link #assertBucket} on the before/after/between buckets of a
   * facet result, based on the {@link FacetRangeOther} specified for this facet.
   *
   * @see #assertBucket
   * @see #buildListOfFacetRangeOtherOptions
   */
  private static void assertBeforeAfterBetween(
      final EnumSet<FacetRangeOther> other,
      final ModelRange before,
      final ModelRange after,
      final ModelRange between,
      final Integer subFacetLimitUsed,
      final NamedList<Object> facet) {
    // final String[] names = new String[] { "before", "after", "between" };
    assertEquals(3, BEFORE_AFTER_BETWEEN.size());
    final ModelRange[] expected = new ModelRange[] {before, after, between};
    for (int i = 0; i < 3; i++) {
      FacetRangeOther key = BEFORE_AFTER_BETWEEN.get(i);
      String name = key.toString();
      if (other.contains(key) || other.contains(FacetRangeOther.ALL)) {
        assertBucket(name, null, expected[i], subFacetLimitUsed, facet.get(name));
      } else {
        assertNull("unexpected other=" + name, facet.get(name));
      }
    }
  }

  /**
   * A little helper struct to make the method sig of {@link #assertBucket} more readable. If lower
   * (or upper) is negative, then both must be negative and upper must be less than lower -- this
   * indicates that the bucket should be empty.
   *
   * @see #modelVals
   * @see #emptyVals
   */
  private static final class ModelRange {
    public final int lower;
    public final int upper;
    /** Don't use, use the convenience methods */
    public ModelRange(int lower, int upper) {
      if (lower < 0 || upper < 0) {
        assertTrue(lower < 0 && upper < lower);
      } else {
        assertTrue(lower <= upper);
      }
      this.lower = lower;
      this.upper = upper;
    }
  }

  private static ModelRange emptyVals() {
    return new ModelRange(-1, -100);
  }

  private static ModelRange modelVals(int value) {
    return modelVals(value, value);
  }

  private static ModelRange modelVals(int lower, int upper) {
    assertTrue(upper + " < " + lower, lower <= upper);
    assertTrue("negative lower", 0 <= lower);
    assertTrue("negative upper", 0 <= upper);
    return new ModelRange(lower, upper);
  }

  /** randomized helper */
  private static Integer pickSubFacetLimit(final boolean doSubFacet) {
    if (!doSubFacet) {
      return null;
    }
    int result = TestUtil.nextInt(random(), -10, atLeast(TERM_VALUES_RANDOMIZER));
    return (result <= 0) ? -1 : result;
  }
  /** randomized helper */
  private static CharSequence makeSubFacet(final Integer subFacetLimit) {
    if (null == subFacetLimit) {
      return "";
    }
    final StringBuilder result =
        new StringBuilder(", facet:{ bar:{ type:terms, refine:true, field:" + STR_FIELD);
    // constrain overrequesting to stress refinement, but still test those code paths
    final String overrequest = random().nextBoolean() ? "0" : "1";

    result.append(", overrequest:").append(overrequest).append(", limit:").append(subFacetLimit);

    // order should have no effect on our testing
    if (random().nextBoolean()) {
      result.append(", sort:'").append(SORTS.get(random().nextInt(SORTS.size()))).append("'");
    }
    result.append("} }");
    return result;
  }

  /**
   * Helper for seeding the re-used static struct, and asserting no one changes the Enum w/o
   * updating this test
   *
   * @see #assertBeforeAfterBetween
   * @see #formatFacetRangeOther
   * @see #OTHERS
   */
  private static List<EnumSet<FacetRangeOther>> buildListOfFacetRangeOtherOptions() {
    assertEquals(
        "If someone adds to FacetRangeOther this method (and bulk of test) needs updated",
        5,
        EnumSet.allOf(FacetRangeOther.class).size());

    // we're not overly concerned about testing *EVERY* permutation,
    // we just want to make sure we test multiple code paths (some, all, "ALL", none)
    //
    // NOTE: Don't mix "ALL" or "NONE" with other options, so we don't have to make
    // assertBeforeAfterBetween overly complicated
    ArrayList<EnumSet<FacetRangeOther>> results = new ArrayList<>(5);
    results.add(EnumSet.of(FacetRangeOther.ALL));
    results.add(EnumSet.of(FacetRangeOther.BEFORE, FacetRangeOther.AFTER, FacetRangeOther.BETWEEN));
    results.add(EnumSet.of(FacetRangeOther.BEFORE, FacetRangeOther.AFTER));
    results.add(EnumSet.of(FacetRangeOther.BETWEEN));
    results.add(EnumSet.of(FacetRangeOther.NONE));
    return results;
  }

  /**
   * @see #assertBeforeAfterBetween
   * @see #buildListOfFacetRangeOtherOptions
   */
  private static String formatFacetRangeOther(EnumSet<FacetRangeOther> other) {
    if (other.contains(FacetRangeOther.NONE) && random().nextBoolean()) {
      return ""; // sometimes don't output a param at all when we're dealing with the default NONE
    }
    String val = other.toString();
    if (random().nextBoolean()) {
      // two valid syntaxes to randomize between:
      // - a JSON list of items (conveniently the default toString of EnumSet),
      // - a single quoted string containing the comma separated list
      val = val.replaceAll("\\[|\\]", "'");
    }
    return ", other:" + val;
  }

  @AfterClass
  public static void afterClass() throws Exception {
    IOUtils.closeQuietly(CLOUD_CLIENT);
  }
}
