/*
 * 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.rya.mongodb.document.operators.query;

import static com.mongodb.assertions.Assertions.notNull;
import static java.util.Arrays.asList;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Pattern;

import org.bson.Document;

import com.mongodb.QueryOperators;
import com.mongodb.lang.Nullable;

/**
 * Utility for creating Document queries
 *
 * This is a {@link Document} based version of {@link com.mongodb.QueryBuilder}.
 * @see com.mongodb.QueryBuilder
 * @mongodb.driver.manual tutorial/query-documents/ Querying
 */
public class QueryBuilder {
    private final Document query;
    private String currentKey;
    private boolean hasNot;

    /**
     * Creates a builder with an empty query
     */
    public QueryBuilder() {
        query = new Document();
    }

    /**
     * Returns a new QueryBuilder.
     *
     * @return a builder
     */
    public static QueryBuilder start() {
        return new QueryBuilder();
    }

    /**
     * Creates a new query with a document key
     *
     * @param key MongoDB document key
     * @return {@code this}
     */
    public static QueryBuilder start(final String key) {
        return (new QueryBuilder()).put(key);
    }

    /**
     * Adds a new key to the query if not present yet. Sets this key as the current key.
     *
     * @param key MongoDB document key
     * @return {@code this}
     */
    public QueryBuilder put(final String key) {
        currentKey = key;
        if (query.get(key) == null) {
            query.put(currentKey, new NullObject());
        }
        return this;
    }

    /**
     * Equivalent to {@code QueryBuilder.put(key)}. Intended for compound query chains to be more readable, e.g. {@code
     * QueryBuilder.start("a").greaterThan(1).and("b").lessThan(3) }
     *
     * @param key MongoDB document key
     * @return {@code this}
     */
    public QueryBuilder and(final String key) {
        return put(key);
    }

    /**
     * Equivalent to the $gt operator
     *
     * @param object Value to query
     * @return {@code this}
     */
    public QueryBuilder greaterThan(final Object object) {
        addOperand(QueryOperators.GT, object);
        return this;
    }

    /**
     * Equivalent to the $gte operator
     *
     * @param object Value to query
     * @return {@code this}
     */
    public QueryBuilder greaterThanEquals(final Object object) {
        addOperand(QueryOperators.GTE, object);
        return this;
    }

    /**
     * Equivalent to the $lt operand
     *
     * @param object Value to query
     * @return {@code this}
     */
    public QueryBuilder lessThan(final Object object) {
        addOperand(QueryOperators.LT, object);
        return this;
    }

    /**
     * Equivalent to the $lte operand
     *
     * @param object Value to query
     * @return {@code this}
     */
    public QueryBuilder lessThanEquals(final Object object) {
        addOperand(QueryOperators.LTE, object);
        return this;
    }

    /**
     * Equivalent of the find({key:value})
     *
     * @param object Value to query
     * @return {@code this}
     */
    public QueryBuilder is(final Object object) {
        addOperand(null, object);
        return this;
    }

    /**
     * Equivalent of the $ne operand
     *
     * @param object Value to query
     * @return {@code this}
     */
    public QueryBuilder notEquals(final Object object) {
        addOperand(QueryOperators.NE, object);
        return this;
    }

    /**
     * Equivalent of the $in operand
     *
     * @param object Value to query
     * @return {@code this}
     */
    public QueryBuilder in(final Object object) {
        addOperand(QueryOperators.IN, object);
        return this;
    }

    /**
     * Equivalent of the $nin operand
     *
     * @param object Value to query
     * @return {@code this}
     */
    public QueryBuilder notIn(final Object object) {
        addOperand(QueryOperators.NIN, object);
        return this;
    }

    /**
     * Equivalent of the $mod operand
     *
     * @param object Value to query
     * @return {@code this}
     */
    public QueryBuilder mod(final Object object) {
        addOperand(QueryOperators.MOD, object);
        return this;
    }

    /**
     * Equivalent of the $all operand
     *
     * @param object Value to query
     * @return {@code this}
     */
    public QueryBuilder all(final Object object) {
        addOperand(QueryOperators.ALL, object);
        return this;
    }

    /**
     * Equivalent of the $size operand
     *
     * @param object Value to query
     * @return {@code this}
     */
    public QueryBuilder size(final Object object) {
        addOperand(QueryOperators.SIZE, object);
        return this;
    }

    /**
     * Equivalent of the $exists operand
     *
     * @param object Value to query
     * @return {@code this}
     */
    public QueryBuilder exists(final Object object) {
        addOperand(QueryOperators.EXISTS, object);
        return this;
    }

    /**
     * Passes a regular expression for a query
     *
     * @param regex Regex pattern object
     * @return {@code this}
     */
    public QueryBuilder regex(final Pattern regex) {
        addOperand(null, regex);
        return this;
    }

    /**
     * Equivalent to the $elemMatch operand
     *
     * @param match the object to match
     * @return {@code this}
     */
    public QueryBuilder elemMatch(final Document match) {
        addOperand(QueryOperators.ELEM_MATCH, match);
        return this;
    }


    /**
     * Equivalent of the $within operand, used for geospatial operation
     *
     * @param x      x coordinate
     * @param y      y coordinate
     * @param radius radius
     * @return {@code this}
     */
    public QueryBuilder withinCenter(final double x, final double y, final double radius) {
        addOperand(QueryOperators.WITHIN,
                   new Document(QueryOperators.CENTER, asList(asList(x, y), radius)));
        return this;
    }

    /**
     * Equivalent of the $near operand
     *
     * @param x x coordinate
     * @param y y coordinate
     * @return {@code this}
     */
    public QueryBuilder near(final double x, final double y) {
        addOperand(QueryOperators.NEAR,
                   asList(x, y));
        return this;
    }

    /**
     * Equivalent of the $near operand
     *
     * @param x           x coordinate
     * @param y           y coordinate
     * @param maxDistance max distance
     * @return {@code this}
     */
    public QueryBuilder near(final double x, final double y, final double maxDistance) {
        addOperand(QueryOperators.NEAR,
                   asList(x, y));
        addOperand(QueryOperators.MAX_DISTANCE,
                   maxDistance);
        return this;
    }

    /**
     * Equivalent of the $nearSphere operand
     *
     * @param longitude coordinate in decimal degrees
     * @param latitude  coordinate in decimal degrees
     * @return {@code this}
     */
    public QueryBuilder nearSphere(final double longitude, final double latitude) {
        addOperand(QueryOperators.NEAR_SPHERE,
                   asList(longitude, latitude));
        return this;
    }

    /**
     * Equivalent of the $nearSphere operand
     *
     * @param longitude   coordinate in decimal degrees
     * @param latitude    coordinate in decimal degrees
     * @param maxDistance max spherical distance
     * @return {@code this}
     */
    public QueryBuilder nearSphere(final double longitude, final double latitude, final double maxDistance) {
        addOperand(QueryOperators.NEAR_SPHERE,
                   asList(longitude, latitude));
        addOperand(QueryOperators.MAX_DISTANCE,
                   maxDistance);
        return this;
    }

    /**
     * Equivalent of the $centerSphere operand mostly intended for queries up to a few hundred miles or km.
     *
     * @param longitude   coordinate in decimal degrees
     * @param latitude    coordinate in decimal degrees
     * @param maxDistance max spherical distance
     * @return {@code this}
     */
    public QueryBuilder withinCenterSphere(final double longitude, final double latitude, final double maxDistance) {
        addOperand(QueryOperators.WITHIN,
                   new Document(QueryOperators.CENTER_SPHERE,
                                     asList(asList(longitude, latitude), maxDistance)));
        return this;
    }

    /**
     * Equivalent to a $within operand, based on a bounding box using represented by two corners
     *
     * @param x  the x coordinate of the first box corner.
     * @param y  the y coordinate of the first box corner.
     * @param x2 the x coordinate of the second box corner.
     * @param y2 the y coordinate of the second box corner.
     * @return {@code this}
     */
    public QueryBuilder withinBox(final double x, final double y, final double x2, final double y2) {
        addOperand(QueryOperators.WITHIN,
                   new Document(QueryOperators.BOX, new Object[]{new Double[]{x, y}, new Double[]{x2, y2}}));
        return this;
    }

    /**
     * Equivalent to a $within operand, based on a bounding polygon represented by an array of points
     *
     * @param points an array of Double[] defining the vertices of the search area
     * @return {@code this}
     */
    public QueryBuilder withinPolygon(final List<Double[]> points) {
        notNull("points", points);
        if (points.isEmpty() || points.size() < 3) {
            throw new IllegalArgumentException("Polygon insufficient number of vertices defined");
        }
        addOperand(QueryOperators.WITHIN,
                   new Document(QueryOperators.POLYGON, convertToListOfLists(points)));
        return this;
    }

    private List<List<Double>> convertToListOfLists(final List<Double[]> points) {
        final List<List<Double>> listOfLists = new ArrayList<List<Double>>(points.size());
        for (final Double[] cur : points) {
            final List<Double> list = new ArrayList<Double>(cur.length);
            Collections.addAll(list, cur);
            listOfLists.add(list);
        }
        return listOfLists;
    }

    /**
     * Equivalent to a $text operand.
     *
     * @param search the search terms to apply to the text index.
     * @return {@code this}
     * @mongodb.server.release 2.6
     */
    public QueryBuilder text(final String search) {
        return text(search, null);
    }

    /**
     * Equivalent to a $text operand.
     *
     * @param search   the search terms to apply to the text index.
     * @param language the language to use.
     * @return {@code this}
     * @mongodb.server.release 2.6
     */
    public QueryBuilder text(final String search, @Nullable final String language) {
        if (currentKey != null) {
            throw new QueryBuilderException("The text operand may only occur at the top-level of a query. It does"
                                            + " not apply to a specific element, but rather to a document as a whole.");
        }

        put(QueryOperators.TEXT);
        addOperand(QueryOperators.SEARCH, search);
        if (language != null) {
            addOperand(QueryOperators.LANGUAGE, language);
        }

        return this;
    }

    /**
     * Equivalent to $not meta operator. Must be followed by an operand, not a value, e.g. {@code
     * QueryBuilder.start("val").not().mod(Arrays.asList(10, 1)) }
     *
     * @return {@code this}
     */
    public QueryBuilder not() {
        hasNot = true;
        return this;
    }

    /**
     * Equivalent to an $or operand
     *
     * @param ors the list of conditions to or together
     * @return {@code this}
     */
    public QueryBuilder or(final Document... ors) {
        List<Object> l = query.getList(QueryOperators.OR, Object.class);
        if (l == null) {
            l = new ArrayList<>();
            query.put(QueryOperators.OR, l);
        }
        Collections.addAll(l, ors);
        return this;
    }

    /**
     * Equivalent to an $and operand
     *
     * @param ands the list of conditions to and together
     * @return {@code this}
     */
    public QueryBuilder and(final Document... ands) {
        List<Object> l = query.getList(QueryOperators.AND, Object.class);
        if (l == null) {
            l = new ArrayList<>();
            query.put(QueryOperators.AND, l);
        }
        Collections.addAll(l, ands);
        return this;
    }

    /**
     * Creates a {@code Document} query to be used for the driver's find operations
     *
     * @return {@code this}
     * @throws RuntimeException if a key does not have a matching operand
     */
    public Document get() {
        for (final String key : query.keySet()) {
            if (query.get(key) instanceof NullObject) {
                throw new QueryBuilderException("No operand for key:" + key);
            }
        }
        return query;
    }

    private void addOperand(@Nullable final String op, final Object value) {
        Object valueToPut = value;
        if (op == null) {
            if (hasNot) {
                valueToPut = new Document(QueryOperators.NOT, valueToPut);
                hasNot = false;
            }
            query.put(currentKey, valueToPut);
            return;
        }

        final Object storedValue = query.get(currentKey);
        Document operand;
        if (!(storedValue instanceof Document)) {
            operand = new Document();
            if (hasNot) {
                final Document notOperand = new Document(QueryOperators.NOT, operand);
                query.put(currentKey, notOperand);
                hasNot = false;
            } else {
                query.put(currentKey, operand);
            }
        } else {
            operand = (Document) query.get(currentKey);
            if (operand.get(QueryOperators.NOT) != null) {
                operand = (Document) operand.get(QueryOperators.NOT);
            }
        }
        operand.put(op, valueToPut);
    }

    static class QueryBuilderException extends RuntimeException {
        private static final long serialVersionUID = 1L;

        QueryBuilderException(final String message) {
            super(message);
        }
    }

    private static class NullObject {
    }
}