/*
 * 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.lucene.spatial.bbox;

import org.apache.lucene.document.DoubleDocValuesField;
import org.apache.lucene.document.DoublePoint;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.FieldType;
import org.apache.lucene.document.StoredField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DocValuesType;
import org.apache.lucene.index.IndexOptions;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.ConstantScoreQuery;
import org.apache.lucene.search.DoubleValuesSource;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.spatial.ShapeValuesSource;
import org.apache.lucene.spatial.SpatialStrategy;
import org.apache.lucene.spatial.query.SpatialArgs;
import org.apache.lucene.spatial.query.SpatialOperation;
import org.apache.lucene.spatial.query.UnsupportedSpatialOperation;
import org.apache.lucene.spatial.util.DistanceToShapeValueSource;
import org.locationtech.spatial4j.context.SpatialContext;
import org.locationtech.spatial4j.shape.Point;
import org.locationtech.spatial4j.shape.Rectangle;
import org.locationtech.spatial4j.shape.Shape;

/**
 * A SpatialStrategy for indexing and searching Rectangles by storing its coordinates in numeric
 * fields. It supports all {@link SpatialOperation}s and has a custom overlap relevancy. It is based
 * on GeoPortal's <a
 * href="http://geoportal.svn.sourceforge.net/svnroot/geoportal/Geoportal/trunk/src/com/esri/gpt/catalog/lucene/SpatialClauseAdapter.java">SpatialClauseAdapter</a>.
 *
 * <p><b>Characteristics:</b> <br>
 *
 * <ul>
 *   <li>Only indexes Rectangles; just one per field value. Other shapes can be provided and the
 *       bounding box will be used.
 *   <li>Can query only by a Rectangle. Providing other shapes is an error.
 *   <li>Supports most {@link SpatialOperation}s but not Overlaps.
 *   <li>Uses the DocValues API for any sorting / relevancy.
 * </ul>
 *
 * <p><b>Implementation:</b>
 *
 * <p>This uses 4 double fields for minX, maxX, minY, maxY and a boolean to mark a dateline cross.
 * Depending on the particular {@link SpatialOperation}s, there are a variety of range queries on
 * {@link DoublePoint}s to be done. The {@link
 * #makeOverlapRatioValueSource(org.locationtech.spatial4j.shape.Rectangle, double)} works by
 * calculating the query bbox overlap percentage against the indexed shape overlap percentage. The
 * indexed shape's coordinates are retrieved from {@link
 * org.apache.lucene.index.LeafReader#getNumericDocValues}.
 *
 * @lucene.experimental
 */
public class BBoxStrategy extends SpatialStrategy {

  // note: we use a FieldType to articulate the options we want on the field.  We don't use it as-is
  // with a Field, we
  //  create more than one Field.

  /** pointValues, docValues, and nothing else. */
  public static FieldType DEFAULT_FIELDTYPE;

  static {
    // Default: pointValues + docValues
    FieldType type = new FieldType();
    type.setDimensions(1, Double.BYTES); // pointValues (assume Double)
    type.setDocValuesType(DocValuesType.NUMERIC); // docValues
    type.setStored(false);
    type.freeze();
    DEFAULT_FIELDTYPE = type;
  }

  public static final String SUFFIX_MINX = "__minX";
  public static final String SUFFIX_MAXX = "__maxX";
  public static final String SUFFIX_MINY = "__minY";
  public static final String SUFFIX_MAXY = "__maxY";
  public static final String SUFFIX_XDL = "__xdl";

  /*
   * The Bounding Box gets stored as four fields for x/y min/max and a flag
   * that says if the box crosses the dateline (xdl).
   */
  final String field_bbox;
  final String field_minX;
  final String field_minY;
  final String field_maxX;
  final String field_maxY;
  final String field_xdl; // crosses dateline

  // from constructor; aggregate field type used to express all options
  private final FieldType optionsFieldType;
  private final int fieldsLen;
  private final boolean hasStored;
  private final boolean hasDocVals;
  private final boolean hasPointVals;
  private final FieldType xdlFieldType;

  /**
   * Creates a new {@link BBoxStrategy} instance that uses {@link DoublePoint} and {@link
   * DoublePoint#newRangeQuery}
   */
  public static BBoxStrategy newInstance(SpatialContext ctx, String fieldNamePrefix) {
    return new BBoxStrategy(ctx, fieldNamePrefix, DEFAULT_FIELDTYPE);
  }

  /**
   * Creates this strategy. {@code fieldType} is used to customize the indexing options of the 4
   * number fields, and to a lesser degree the XDL field too. Search requires pointValues (or legacy
   * numerics), and relevancy requires docValues. If these features aren't needed then disable them.
   */
  public BBoxStrategy(SpatialContext ctx, String fieldNamePrefix, FieldType fieldType) {
    super(ctx, fieldNamePrefix);
    field_bbox = fieldNamePrefix;
    field_minX = fieldNamePrefix + SUFFIX_MINX;
    field_maxX = fieldNamePrefix + SUFFIX_MAXX;
    field_minY = fieldNamePrefix + SUFFIX_MINY;
    field_maxY = fieldNamePrefix + SUFFIX_MAXY;
    field_xdl = fieldNamePrefix + SUFFIX_XDL;

    fieldType.freeze();
    this.optionsFieldType = fieldType;

    int numQuads = 0;
    if ((this.hasStored = fieldType.stored())) {
      numQuads++;
    }
    if ((this.hasDocVals = fieldType.docValuesType() != DocValuesType.NONE)) {
      numQuads++;
    }
    if ((this.hasPointVals = fieldType.pointDimensionCount() > 0)) {
      numQuads++;
    }

    if (hasPointVals) { // if we have an index...
      xdlFieldType = new FieldType(StringField.TYPE_NOT_STORED);
      xdlFieldType.setIndexOptions(IndexOptions.DOCS);
      xdlFieldType.freeze();
    } else {
      xdlFieldType = null;
    }

    this.fieldsLen = numQuads * 4 + (xdlFieldType != null ? 1 : 0);
  }

  /**
   * Returns a field type representing the set of field options. This is identical to what was
   * passed into the constructor. It's frozen.
   */
  public FieldType getFieldType() {
    return optionsFieldType;
  }

  // ---------------------------------
  // Indexing
  // ---------------------------------

  @Override
  public Field[] createIndexableFields(Shape shape) {
    return createIndexableFields(shape.getBoundingBox());
  }

  private Field[] createIndexableFields(Rectangle bbox) {
    Field[] fields = new Field[fieldsLen];
    int idx = -1;
    if (hasStored) {
      fields[++idx] = new StoredField(field_minX, bbox.getMinX());
      fields[++idx] = new StoredField(field_minY, bbox.getMinY());
      fields[++idx] = new StoredField(field_maxX, bbox.getMaxX());
      fields[++idx] = new StoredField(field_maxY, bbox.getMaxY());
    }
    if (hasDocVals) {
      fields[++idx] = new DoubleDocValuesField(field_minX, bbox.getMinX());
      fields[++idx] = new DoubleDocValuesField(field_minY, bbox.getMinY());
      fields[++idx] = new DoubleDocValuesField(field_maxX, bbox.getMaxX());
      fields[++idx] = new DoubleDocValuesField(field_maxY, bbox.getMaxY());
    }
    if (hasPointVals) {
      fields[++idx] = new DoublePoint(field_minX, bbox.getMinX());
      fields[++idx] = new DoublePoint(field_minY, bbox.getMinY());
      fields[++idx] = new DoublePoint(field_maxX, bbox.getMaxX());
      fields[++idx] = new DoublePoint(field_maxY, bbox.getMaxY());
    }
    if (xdlFieldType != null) {
      fields[++idx] = new Field(field_xdl, bbox.getCrossesDateLine() ? "T" : "F", xdlFieldType);
    }
    assert idx == fields.length - 1;
    return fields;
  }

  // ---------------------------------
  // Value Source / Relevancy
  // ---------------------------------

  /** Provides access to each rectangle per document as a {@link ShapeValuesSource} */
  // TODO raise to SpatialStrategy
  public ShapeValuesSource makeShapeValueSource() {
    return new BBoxValueSource(this);
  }

  @Override
  public DoubleValuesSource makeDistanceValueSource(Point queryPoint, double multiplier) {
    // TODO if makeShapeValueSource gets lifted to the top; this could become a generic impl.
    return new DistanceToShapeValueSource(makeShapeValueSource(), queryPoint, multiplier, ctx);
  }

  /**
   * Returns a similarity based on {@link BBoxOverlapRatioValueSource}. This is just a convenience
   * method.
   */
  public DoubleValuesSource makeOverlapRatioValueSource(
      Rectangle queryBox, double queryTargetProportion) {
    return new BBoxOverlapRatioValueSource(
        makeShapeValueSource(), ctx.isGeo(), queryBox, queryTargetProportion, 0.0);
  }

  // ---------------------------------
  // Query Building
  // ---------------------------------

  //  Utility on SpatialStrategy?
  //  public Query makeQueryWithValueSource(SpatialArgs args, ValueSource valueSource) {
  //    return new CustomScoreQuery(makeQuery(args), new FunctionQuery(valueSource));
  // or...
  //  return new BooleanQuery.Builder()
  //      .add(new FunctionQuery(valueSource), BooleanClause.Occur.MUST)//matches everything and
  // provides score
  //      .add(filterQuery, BooleanClause.Occur.FILTER)//filters (score isn't used)
  //  .build();
  //  }

  @Override
  public Query makeQuery(SpatialArgs args) {
    Shape shape = args.getShape();
    if (!(shape instanceof Rectangle))
      throw new UnsupportedOperationException("Can only query by Rectangle, not " + shape);

    Rectangle bbox = (Rectangle) shape;
    Query spatial;

    // Useful for understanding Relations:
    // http://edndoc.esri.com/arcsde/9.1/general_topics/understand_spatial_relations.htm
    SpatialOperation op = args.getOperation();
    if (op == SpatialOperation.BBoxIntersects) spatial = makeIntersects(bbox);
    else if (op == SpatialOperation.BBoxWithin) spatial = makeWithin(bbox);
    else if (op == SpatialOperation.Contains) spatial = makeContains(bbox);
    else if (op == SpatialOperation.Intersects) spatial = makeIntersects(bbox);
    else if (op == SpatialOperation.IsEqualTo) spatial = makeEquals(bbox);
    else if (op == SpatialOperation.IsDisjointTo) spatial = makeDisjoint(bbox);
    else if (op == SpatialOperation.IsWithin) spatial = makeWithin(bbox);
    else { // no Overlaps support yet
      throw new UnsupportedSpatialOperation(op);
    }
    return new ConstantScoreQuery(spatial);
  }

  /**
   * Constructs a query to retrieve documents that fully contain the input envelope.
   *
   * @return the spatial query
   */
  Query makeContains(Rectangle bbox) {

    // general case
    // docMinX <= queryExtent.getMinX() AND docMinY <= queryExtent.getMinY() AND docMaxX >=
    // queryExtent.getMaxX() AND docMaxY >= queryExtent.getMaxY()

    // Y conditions
    // docMinY <= queryExtent.getMinY() AND docMaxY >= queryExtent.getMaxY()
    Query qMinY = this.makeNumericRangeQuery(field_minY, null, bbox.getMinY(), false, true);
    Query qMaxY = this.makeNumericRangeQuery(field_maxY, bbox.getMaxY(), null, true, false);
    Query yConditions = this.makeQuery(BooleanClause.Occur.MUST, qMinY, qMaxY);

    // X conditions
    Query xConditions;

    // queries that do not cross the date line
    if (!bbox.getCrossesDateLine()) {

      // X Conditions for documents that do not cross the date line,
      // documents that contain the min X and max X of the query envelope,
      // docMinX <= queryExtent.getMinX() AND docMaxX >= queryExtent.getMaxX()
      Query qMinX = this.makeNumericRangeQuery(field_minX, null, bbox.getMinX(), false, true);
      Query qMaxX = this.makeNumericRangeQuery(field_maxX, bbox.getMaxX(), null, true, false);
      Query qMinMax = this.makeQuery(BooleanClause.Occur.MUST, qMinX, qMaxX);
      Query qNonXDL = this.makeXDL(false, qMinMax);

      if (!ctx.isGeo()) {
        xConditions = qNonXDL;
      } else {
        // X Conditions for documents that cross the date line,
        // the left portion of the document contains the min X of the query
        // OR the right portion of the document contains the max X of the query,
        // docMinXLeft <= queryExtent.getMinX() OR docMaxXRight >= queryExtent.getMaxX()
        Query qXDLLeft = this.makeNumericRangeQuery(field_minX, null, bbox.getMinX(), false, true);
        Query qXDLRight = this.makeNumericRangeQuery(field_maxX, bbox.getMaxX(), null, true, false);
        Query qXDLLeftRight = this.makeQuery(BooleanClause.Occur.SHOULD, qXDLLeft, qXDLRight);
        Query qXDL = this.makeXDL(true, qXDLLeftRight);

        Query qEdgeDL = null;
        if (bbox.getMinX() == bbox.getMaxX() && Math.abs(bbox.getMinX()) == 180) {
          double edge = bbox.getMinX() * -1; // opposite dateline edge
          qEdgeDL =
              makeQuery(
                  BooleanClause.Occur.SHOULD,
                  makeNumberTermQuery(field_minX, edge),
                  makeNumberTermQuery(field_maxX, edge));
        }

        // apply the non-XDL and XDL conditions
        xConditions = this.makeQuery(BooleanClause.Occur.SHOULD, qNonXDL, qXDL, qEdgeDL);
      }
    } else {
      // queries that cross the date line

      // No need to search for documents that do not cross the date line

      // X Conditions for documents that cross the date line,
      // the left portion of the document contains the min X of the query
      // AND the right portion of the document contains the max X of the query,
      // docMinXLeft <= queryExtent.getMinX() AND docMaxXRight >= queryExtent.getMaxX()
      Query qXDLLeft = this.makeNumericRangeQuery(field_minX, null, bbox.getMinX(), false, true);
      Query qXDLRight = this.makeNumericRangeQuery(field_maxX, bbox.getMaxX(), null, true, false);
      Query qXDLLeftRight =
          this.makeXDL(true, this.makeQuery(BooleanClause.Occur.MUST, qXDLLeft, qXDLRight));

      Query qWorld =
          makeQuery(
              BooleanClause.Occur.MUST,
              makeNumberTermQuery(field_minX, -180),
              makeNumberTermQuery(field_maxX, 180));

      xConditions = makeQuery(BooleanClause.Occur.SHOULD, qXDLLeftRight, qWorld);
    }

    // both X and Y conditions must occur
    return this.makeQuery(BooleanClause.Occur.MUST, xConditions, yConditions);
  }

  /**
   * Constructs a query to retrieve documents that are disjoint to the input envelope.
   *
   * @return the spatial query
   */
  Query makeDisjoint(Rectangle bbox) {

    // general case
    // docMinX > queryExtent.getMaxX() OR docMaxX < queryExtent.getMinX() OR docMinY >
    // queryExtent.getMaxY() OR docMaxY < queryExtent.getMinY()

    // Y conditions
    // docMinY > queryExtent.getMaxY() OR docMaxY < queryExtent.getMinY()
    Query qMinY = this.makeNumericRangeQuery(field_minY, bbox.getMaxY(), null, false, false);
    Query qMaxY = this.makeNumericRangeQuery(field_maxY, null, bbox.getMinY(), false, false);
    Query yConditions = this.makeQuery(BooleanClause.Occur.SHOULD, qMinY, qMaxY);

    // X conditions
    Query xConditions;

    // queries that do not cross the date line
    if (!bbox.getCrossesDateLine()) {

      // X Conditions for documents that do not cross the date line,
      // docMinX > queryExtent.getMaxX() OR docMaxX < queryExtent.getMinX()
      Query qMinX = this.makeNumericRangeQuery(field_minX, bbox.getMaxX(), null, false, false);
      if (bbox.getMinX() == -180.0 && ctx.isGeo()) { // touches dateline; -180 == 180
        BooleanQuery.Builder bq = new BooleanQuery.Builder();
        bq.add(qMinX, BooleanClause.Occur.MUST);
        bq.add(makeNumberTermQuery(field_maxX, 180.0), BooleanClause.Occur.MUST_NOT);
        qMinX = bq.build();
      }
      Query qMaxX = this.makeNumericRangeQuery(field_maxX, null, bbox.getMinX(), false, false);

      if (bbox.getMaxX() == 180.0 && ctx.isGeo()) { // touches dateline; -180 == 180
        BooleanQuery.Builder bq = new BooleanQuery.Builder();
        bq.add(qMaxX, BooleanClause.Occur.MUST);
        bq.add(makeNumberTermQuery(field_minX, -180.0), BooleanClause.Occur.MUST_NOT);
        qMaxX = bq.build();
      }
      Query qMinMax = this.makeQuery(BooleanClause.Occur.SHOULD, qMinX, qMaxX);
      Query qNonXDL = this.makeXDL(false, qMinMax);

      if (!ctx.isGeo()) {
        xConditions = qNonXDL;
      } else {
        // X Conditions for documents that cross the date line,

        // both the left and right portions of the document must be disjoint to the query
        // (docMinXLeft > queryExtent.getMaxX() OR docMaxXLeft < queryExtent.getMinX()) AND
        // (docMinXRight > queryExtent.getMaxX() OR docMaxXRight < queryExtent.getMinX())
        // where: docMaxXLeft = 180.0, docMinXRight = -180.0
        // (docMaxXLeft  < queryExtent.getMinX()) equates to (180.0  < queryExtent.getMinX()) and is
        // ignored
        // (docMinXRight > queryExtent.getMaxX()) equates to (-180.0 > queryExtent.getMaxX()) and is
        // ignored
        Query qMinXLeft =
            this.makeNumericRangeQuery(field_minX, bbox.getMaxX(), null, false, false);
        Query qMaxXRight =
            this.makeNumericRangeQuery(field_maxX, null, bbox.getMinX(), false, false);
        Query qLeftRight = this.makeQuery(BooleanClause.Occur.MUST, qMinXLeft, qMaxXRight);
        Query qXDL = this.makeXDL(true, qLeftRight);

        // apply the non-XDL and XDL conditions
        xConditions = this.makeQuery(BooleanClause.Occur.SHOULD, qNonXDL, qXDL);
      }
      // queries that cross the date line
    } else {

      // X Conditions for documents that do not cross the date line,
      // the document must be disjoint to both the left and right query portions
      // (docMinX > queryExtent.getMaxX()Left OR docMaxX < queryExtent.getMinX()) AND (docMinX >
      // queryExtent.getMaxX() OR docMaxX < queryExtent.getMinX()Left)
      // where: queryExtent.getMaxX()Left = 180.0, queryExtent.getMinX()Left = -180.0
      Query qMinXLeft = this.makeNumericRangeQuery(field_minX, 180.0, null, false, false);
      Query qMaxXLeft = this.makeNumericRangeQuery(field_maxX, null, bbox.getMinX(), false, false);
      Query qMinXRight = this.makeNumericRangeQuery(field_minX, bbox.getMaxX(), null, false, false);
      Query qMaxXRight = this.makeNumericRangeQuery(field_maxX, null, -180.0, false, false);
      Query qLeft = this.makeQuery(BooleanClause.Occur.SHOULD, qMinXLeft, qMaxXLeft);
      Query qRight = this.makeQuery(BooleanClause.Occur.SHOULD, qMinXRight, qMaxXRight);
      Query qLeftRight = this.makeQuery(BooleanClause.Occur.MUST, qLeft, qRight);

      // No need to search for documents that do not cross the date line

      xConditions = this.makeXDL(false, qLeftRight);
    }

    // either X or Y conditions should occur
    return this.makeQuery(BooleanClause.Occur.SHOULD, xConditions, yConditions);
  }

  /**
   * Constructs a query to retrieve documents that equal the input envelope.
   *
   * @return the spatial query
   */
  Query makeEquals(Rectangle bbox) {

    // docMinX = queryExtent.getMinX() AND docMinY = queryExtent.getMinY() AND docMaxX =
    // queryExtent.getMaxX() AND docMaxY = queryExtent.getMaxY()
    Query qMinX = makeNumberTermQuery(field_minX, bbox.getMinX());
    Query qMinY = makeNumberTermQuery(field_minY, bbox.getMinY());
    Query qMaxX = makeNumberTermQuery(field_maxX, bbox.getMaxX());
    Query qMaxY = makeNumberTermQuery(field_maxY, bbox.getMaxY());
    return makeQuery(BooleanClause.Occur.MUST, qMinX, qMinY, qMaxX, qMaxY);
  }

  /**
   * Constructs a query to retrieve documents that intersect the input envelope.
   *
   * @return the spatial query
   */
  Query makeIntersects(Rectangle bbox) {

    // the original intersects query does not work for envelopes that cross the date line,
    // switch to a NOT Disjoint query

    // MUST_NOT causes a problem when it's the only clause type within a BooleanQuery,
    // to get around it we add all documents as a SHOULD

    // there must be an envelope, it must not be disjoint
    Query qHasEnv;
    if (ctx.isGeo()) {
      Query qIsNonXDL = this.makeXDL(false);
      Query qIsXDL = ctx.isGeo() ? this.makeXDL(true) : null;
      qHasEnv = this.makeQuery(BooleanClause.Occur.SHOULD, qIsNonXDL, qIsXDL);
    } else {
      qHasEnv = this.makeXDL(false);
    }

    BooleanQuery.Builder qNotDisjoint = new BooleanQuery.Builder();
    qNotDisjoint.add(qHasEnv, BooleanClause.Occur.MUST);
    Query qDisjoint = makeDisjoint(bbox);
    qNotDisjoint.add(qDisjoint, BooleanClause.Occur.MUST_NOT);

    // Query qDisjoint = makeDisjoint();
    // BooleanQuery qNotDisjoint = new BooleanQuery();
    // qNotDisjoint.add(new MatchAllDocsQuery(),BooleanClause.Occur.SHOULD);
    // qNotDisjoint.add(qDisjoint,BooleanClause.Occur.MUST_NOT);
    return qNotDisjoint.build();
  }

  /**
   * Makes a boolean query based upon a collection of queries and a logical operator.
   *
   * @param occur the logical operator
   * @param queries the query collection
   * @return the query
   */
  BooleanQuery makeQuery(BooleanClause.Occur occur, Query... queries) {
    BooleanQuery.Builder bq = new BooleanQuery.Builder();
    for (Query query : queries) {
      if (query != null) bq.add(query, occur);
    }
    return bq.build();
  }

  /**
   * Constructs a query to retrieve documents are fully within the input envelope.
   *
   * @return the spatial query
   */
  Query makeWithin(Rectangle bbox) {

    // general case
    // docMinX >= queryExtent.getMinX() AND docMinY >= queryExtent.getMinY() AND docMaxX <=
    // queryExtent.getMaxX() AND docMaxY <= queryExtent.getMaxY()

    // Y conditions
    // docMinY >= queryExtent.getMinY() AND docMaxY <= queryExtent.getMaxY()
    Query qMinY = this.makeNumericRangeQuery(field_minY, bbox.getMinY(), null, true, false);
    Query qMaxY = this.makeNumericRangeQuery(field_maxY, null, bbox.getMaxY(), false, true);
    Query yConditions = this.makeQuery(BooleanClause.Occur.MUST, qMinY, qMaxY);

    // X conditions
    Query xConditions;

    if (ctx.isGeo() && bbox.getMinX() == -180.0 && bbox.getMaxX() == 180.0) {
      // if query world-wraps, only the y condition matters
      return yConditions;

    } else if (!bbox.getCrossesDateLine()) {
      // queries that do not cross the date line

      // docMinX >= queryExtent.getMinX() AND docMaxX <= queryExtent.getMaxX()
      Query qMinX = this.makeNumericRangeQuery(field_minX, bbox.getMinX(), null, true, false);
      Query qMaxX = this.makeNumericRangeQuery(field_maxX, null, bbox.getMaxX(), false, true);
      Query qMinMax = this.makeQuery(BooleanClause.Occur.MUST, qMinX, qMaxX);

      double edge = 0; // none, otherwise opposite dateline of query
      if (bbox.getMinX() == -180.0) edge = 180;
      else if (bbox.getMaxX() == 180.0) edge = -180;
      if (edge != 0 && ctx.isGeo()) {
        Query edgeQ =
            makeQuery(
                BooleanClause.Occur.MUST,
                makeNumberTermQuery(field_minX, edge),
                makeNumberTermQuery(field_maxX, edge));
        qMinMax = makeQuery(BooleanClause.Occur.SHOULD, qMinMax, edgeQ);
      }

      xConditions = this.makeXDL(false, qMinMax);

      // queries that cross the date line
    } else {

      // X Conditions for documents that do not cross the date line

      // the document should be within the left portion of the query
      // docMinX >= queryExtent.getMinX() AND docMaxX <= 180.0
      Query qMinXLeft = this.makeNumericRangeQuery(field_minX, bbox.getMinX(), null, true, false);
      Query qMaxXLeft = this.makeNumericRangeQuery(field_maxX, null, 180.0, false, true);
      Query qLeft = this.makeQuery(BooleanClause.Occur.MUST, qMinXLeft, qMaxXLeft);

      // the document should be within the right portion of the query
      // docMinX >= -180.0 AND docMaxX <= queryExtent.getMaxX()
      Query qMinXRight = this.makeNumericRangeQuery(field_minX, -180.0, null, true, false);
      Query qMaxXRight = this.makeNumericRangeQuery(field_maxX, null, bbox.getMaxX(), false, true);
      Query qRight = this.makeQuery(BooleanClause.Occur.MUST, qMinXRight, qMaxXRight);

      // either left or right conditions should occur,
      // apply the left and right conditions to documents that do not cross the date line
      Query qLeftRight = this.makeQuery(BooleanClause.Occur.SHOULD, qLeft, qRight);
      Query qNonXDL = this.makeXDL(false, qLeftRight);

      // X Conditions for documents that cross the date line,
      // the left portion of the document must be within the left portion of the query,
      // AND the right portion of the document must be within the right portion of the query
      // docMinXLeft >= queryExtent.getMinX() AND docMaxXLeft <= 180.0
      // AND docMinXRight >= -180.0 AND docMaxXRight <= queryExtent.getMaxX()
      Query qXDLLeft = this.makeNumericRangeQuery(field_minX, bbox.getMinX(), null, true, false);
      Query qXDLRight = this.makeNumericRangeQuery(field_maxX, null, bbox.getMaxX(), false, true);
      Query qXDLLeftRight = this.makeQuery(BooleanClause.Occur.MUST, qXDLLeft, qXDLRight);
      Query qXDL = this.makeXDL(true, qXDLLeftRight);

      // apply the non-XDL and XDL conditions
      xConditions = this.makeQuery(BooleanClause.Occur.SHOULD, qNonXDL, qXDL);
    }

    // both X and Y conditions must occur
    return this.makeQuery(BooleanClause.Occur.MUST, xConditions, yConditions);
  }

  /**
   * Constructs a query to retrieve documents that do or do not cross the date line.
   *
   * @param crossedDateLine <code>true</true> for documents that cross the date line
   * @return the query
   */
  private Query makeXDL(boolean crossedDateLine) {
    // The 'T' and 'F' values match solr fields
    return new TermQuery(new Term(field_xdl, crossedDateLine ? "T" : "F"));
  }

  /**
   * Constructs a query to retrieve documents that do or do not cross the date line
   * and match the supplied spatial query.
   *
   * @param crossedDateLine <code>true</true> for documents that cross the date line
   * @param query the spatial query
   * @return the query
   */
  private Query makeXDL(boolean crossedDateLine, Query query) {
    if (!ctx.isGeo()) {
      assert !crossedDateLine;
      return query;
    }
    BooleanQuery.Builder bq = new BooleanQuery.Builder();
    bq.add(this.makeXDL(crossedDateLine), BooleanClause.Occur.MUST);
    bq.add(query, BooleanClause.Occur.MUST);
    return bq.build();
  }

  private Query makeNumberTermQuery(String field, double number) {
    if (hasPointVals) {
      return DoublePoint.newExactQuery(field, number);
    }
    throw new UnsupportedOperationException("An index is required for this operation.");
  }

  /**
   * Returns a numeric range query based on FieldType {@link DoublePoint#newRangeQuery} is used for
   * indexes created using {@link DoublePoint} fields
   *
   * @param fieldname field name. must not be <code>null</code>.
   * @param min minimum value of the range.
   * @param max maximum value of the range.
   * @param minInclusive include the minimum value if <code>true</code>.
   * @param maxInclusive include the maximum value if <code>true</code>
   */
  private Query makeNumericRangeQuery(
      String fieldname, Double min, Double max, boolean minInclusive, boolean maxInclusive) {
    if (hasPointVals) {
      if (min == null) {
        min = Double.NEGATIVE_INFINITY;
      }

      if (max == null) {
        max = Double.POSITIVE_INFINITY;
      }

      if (minInclusive == false) {
        min = Math.nextUp(min);
      }

      if (maxInclusive == false) {
        max = Math.nextDown(max);
      }

      return DoublePoint.newRangeQuery(fieldname, min, max);
    }
    throw new UnsupportedOperationException("An index is required for this operation.");
  }
}
