/*
 * Copyright (c) 2008-2014 MongoDB, Inc.
 *
 * Licensed 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 com.mongodb;

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

import static java.util.Arrays.asList;

/**
 * Utility for creating DBObject queries
 *
 * @mongodb.driver.manual tutorial/query-documents/ Querying
 */
@SuppressWarnings("rawtypes")
public class QueryBuilder {

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

    /**
     * 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 DBObject 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 BasicDBObject(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 BasicDBObject(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}
     */
    @SuppressWarnings("unchecked")
    public QueryBuilder withinBox(final double x, final double y, final double x2, final double y2) {
        addOperand(QueryOperators.WITHIN,
                   new BasicDBObject(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) {
        if (points == null || points.isEmpty() || points.size() < 3) {
            throw new IllegalArgumentException("Polygon insufficient number of vertices defined");
        }
        addOperand(QueryOperators.WITHIN,
                   new BasicDBObject(QueryOperators.POLYGON, convertToListOfLists(points)));
        return this;
    }

    private List<List<Double>> convertToListOfLists(final List<Double[]> points) {
        List<List<Double>> listOfLists = new ArrayList<List<Double>>(points.size());
        for (Double[] cur : points) {
            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, 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}
     */
    @SuppressWarnings("unchecked")
    public QueryBuilder or(final DBObject... ors) {
        List l = (List) _query.get(QueryOperators.OR);
        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}
     */
    @SuppressWarnings("unchecked")
    public QueryBuilder and(final DBObject... ands) {
        List l = (List) _query.get(QueryOperators.AND);
        if (l == null) {
            l = new ArrayList();
            _query.put(QueryOperators.AND, l);
        }
        Collections.addAll(l, ands);
        return this;
    }

    /**
     * Creates a {@code DBObject} 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 DBObject 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(final String op, final Object value) {
        Object valueToPut = value;
        if (op == null) {
            if (_hasNot) {
                valueToPut = new BasicDBObject(QueryOperators.NOT, valueToPut);
                _hasNot = false;
            }
            _query.put(_currentKey, valueToPut);
            return;
        }

        Object storedValue = _query.get(_currentKey);
        BasicDBObject operand;
        if (!(storedValue instanceof DBObject)) {
            operand = new BasicDBObject();
            if (_hasNot) {
                DBObject notOperand = new BasicDBObject(QueryOperators.NOT, operand);
                _query.put(_currentKey, notOperand);
                _hasNot = false;
            } else {
                _query.put(_currentKey, operand);
            }
        } else {
            operand = (BasicDBObject) _query.get(_currentKey);
            if (operand.get(QueryOperators.NOT) != null) {
                operand = (BasicDBObject) operand.get(QueryOperators.NOT);
            }
        }
        operand.put(op, valueToPut);
    }

    @SuppressWarnings("serial")
    static class QueryBuilderException extends RuntimeException {
        QueryBuilderException(final String message) {
            super(message);
        }
    }

    private static class NullObject {
    }

    private final DBObject _query;
    private String _currentKey;
    private boolean _hasNot;

}
