/*
 * Copyright 2008-present 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 org.bson.json;

import org.bson.AbstractBsonReader;
import org.bson.BSONException;
import org.bson.BsonBinary;
import org.bson.BsonBinarySubType;
import org.bson.BsonContextType;
import org.bson.BsonDbPointer;
import org.bson.BsonInvalidOperationException;
import org.bson.BsonReaderMark;
import org.bson.BsonRegularExpression;
import org.bson.BsonTimestamp;
import org.bson.BsonType;
import org.bson.BsonUndefined;
import org.bson.internal.Base64;
import org.bson.types.Decimal128;
import org.bson.types.MaxKey;
import org.bson.types.MinKey;
import org.bson.types.ObjectId;

import java.io.Reader;
import java.text.DateFormat;
import java.text.ParsePosition;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;

import static java.lang.String.format;

/**
 * Reads a JSON in one of the following modes:
 *

 *
Strict mode that conforms to the JSON RFC specifications.

 *
JavaScript mode that that most JavaScript interpreters can process

 *
Shell mode that the mongo shell can process.
 * This is also called "extended" JavaScript format.

 *

 * For more information about this modes please see
 *
 * http://docs.mongodb.org/manual/reference/mongodb-extended-json/
 *
 *
 * @since 3.0
 */
public class JsonReader extends AbstractBsonReader {

    private final JsonScanner scanner;
    private JsonToken pushedToken;
    private Object currentValue;
    private Mark mark;

    /**
     * Constructs a new instance with the given string positioned at a JSON object.
     *
     * @param json     A string representation of a JSON object.
     */
    public JsonReader(final String json) {
        this(new JsonScanner(json));
    }

    /**
     * Constructs a new instance with the given {@code Reader} positioned at a JSON object.
     *
     *

     * The application is responsible for closing the {@code Reader}.
     *

     *
     * @param reader A reader representation of a JSON object.
     * @since 3.11
     */
    public JsonReader(final Reader reader) {
        this(new JsonScanner(reader));
    }

    private JsonReader(final JsonScanner scanner) {
        super();
        this.scanner = scanner;
        setContext(new Context(null, BsonContextType.TOP_LEVEL));
    }

    @Override
    protected BsonBinary doReadBinaryData() {
        return (BsonBinary) currentValue;
    }

    @Override
    protected byte doPeekBinarySubType() {
        return doReadBinaryData().getType();
    }

    @Override
    protected int doPeekBinarySize() {
        return doReadBinaryData().getData().length;
    }

    @Override
    protected boolean doReadBoolean() {
        return (Boolean) currentValue;
    }

    //CHECKSTYLE:OFF
    @Override
    public BsonType readBsonType() {
        if (isClosed()) {
            throw new IllegalStateException("This instance has been closed");
        }
        if (getState() == State.INITIAL || getState() == State.DONE || getState() == State.SCOPE_DOCUMENT) {
            // in JSON the top level value can be of any type so fall through
            setState(State.TYPE);
        }
        if (getState() != State.TYPE) {
            throwInvalidState("readBSONType", State.TYPE);
        }

        if (getContext().getContextType() == BsonContextType.DOCUMENT) {
            JsonToken nameToken = popToken();
            switch (nameToken.getType()) {
                case STRING:
                case UNQUOTED_STRING:
                    setCurrentName(nameToken.getValue(String.class));
                    break;
                case END_OBJECT:
                    setState(State.END_OF_DOCUMENT);
                    return BsonType.END_OF_DOCUMENT;
                default:
                    throw new JsonParseException("JSON reader was expecting a name but found '%s'.", nameToken.getValue());
            }

            JsonToken colonToken = popToken();
            if (colonToken.getType() != JsonTokenType.COLON) {
                throw new JsonParseException("JSON reader was expecting ':' but found '%s'.", colonToken.getValue());
            }
        }

        JsonToken token = popToken();
        if (getContext().getContextType() == BsonContextType.ARRAY && token.getType() == JsonTokenType.END_ARRAY) {
            setState(State.END_OF_ARRAY);
            return BsonType.END_OF_DOCUMENT;
        }

        boolean noValueFound = false;
        switch (token.getType()) {
            case BEGIN_ARRAY:
                setCurrentBsonType(BsonType.ARRAY);
                break;
            case BEGIN_OBJECT:
                visitExtendedJSON();
                break;
            case DOUBLE:
                setCurrentBsonType(BsonType.DOUBLE);
                currentValue = token.getValue();
                break;
            case END_OF_FILE:
                setCurrentBsonType(BsonType.END_OF_DOCUMENT);
                break;
            case INT32:
                setCurrentBsonType(BsonType.INT32);
                currentValue = token.getValue();
                break;
            case INT64:
                setCurrentBsonType(BsonType.INT64);
                currentValue = token.getValue();
                break;
            case REGULAR_EXPRESSION:
                setCurrentBsonType(BsonType.REGULAR_EXPRESSION);
                currentValue = token.getValue();
                break;
            case STRING:
                setCurrentBsonType(BsonType.STRING);
                currentValue = token.getValue();
                break;
            case UNQUOTED_STRING:
                String value = token.getValue(String.class);

                if ("false".equals(value) || "true".equals(value)) {
                    setCurrentBsonType(BsonType.BOOLEAN);
                    currentValue = Boolean.parseBoolean(value);
                } else if ("Infinity".equals(value)) {
                    setCurrentBsonType(BsonType.DOUBLE);
                    currentValue = Double.POSITIVE_INFINITY;
                } else if ("NaN".equals(value)) {
                    setCurrentBsonType(BsonType.DOUBLE);
                    currentValue = Double.NaN;
                } else if ("null".equals(value)) {
                    setCurrentBsonType(BsonType.NULL);
                } else if ("undefined".equals(value)) {
                    setCurrentBsonType(BsonType.UNDEFINED);
                } else if ("MinKey".equals(value)) {
                    visitEmptyConstructor();
                    setCurrentBsonType(BsonType.MIN_KEY);
                    currentValue = new MinKey();
                } else if ("MaxKey".equals(value)) {
                    visitEmptyConstructor();
                    setCurrentBsonType(BsonType.MAX_KEY);
                    currentValue = new MaxKey();
                } else if ("BinData".equals(value)) {
                    setCurrentBsonType(BsonType.BINARY);
                    currentValue = visitBinDataConstructor();
                } else if ("Date".equals(value)) {
                    currentValue = visitDateTimeConstructorWithOutNew();
                    setCurrentBsonType(BsonType.STRING);
                } else if ("HexData".equals(value)) {
                    setCurrentBsonType(BsonType.BINARY);
                    currentValue = visitHexDataConstructor();
                } else if ("ISODate".equals(value)) {
                    setCurrentBsonType(BsonType.DATE_TIME);
                    currentValue = visitISODateTimeConstructor();
                } else if ("NumberInt".equals(value)) {
                    setCurrentBsonType(BsonType.INT32);
                    currentValue = visitNumberIntConstructor();
                } else if ("NumberLong".equals(value)) {
                    setCurrentBsonType(BsonType.INT64);
                    currentValue = visitNumberLongConstructor();
                } else if ("NumberDecimal".equals(value)) {
                    setCurrentBsonType(BsonType.DECIMAL128);
                    currentValue = visitNumberDecimalConstructor();
                } else if ("ObjectId".equals(value)) {
                    setCurrentBsonType(BsonType.OBJECT_ID);
                    currentValue = visitObjectIdConstructor();
                } else if ("Timestamp".equals(value)) {
                    setCurrentBsonType(BsonType.TIMESTAMP);
                    currentValue = visitTimestampConstructor();
                } else if ("RegExp".equals(value)) {
                    setCurrentBsonType(BsonType.REGULAR_EXPRESSION);
                    currentValue = visitRegularExpressionConstructor();
                } else if ("DBPointer".equals(value)) {
                    setCurrentBsonType(BsonType.DB_POINTER);
                    currentValue = visitDBPointerConstructor();
                } else if ("UUID".equals(value)
                           || "GUID".equals(value)
                           || "CSUUID".equals(value)
                           || "CSGUID".equals(value)
                           || "JUUID".equals(value)
                           || "JGUID".equals(value)
                           || "PYUUID".equals(value)
                           || "PYGUID".equals(value)) {
                    setCurrentBsonType(BsonType.BINARY);
                    currentValue = visitUUIDConstructor(value);
                } else if ("new".equals(value)) {
                    visitNew();
                } else {
                    noValueFound = true;
                }
                break;
            default:
                noValueFound = true;
                break;
        }
        if (noValueFound) {
            throw new JsonParseException("JSON reader was expecting a value but found '%s'.", token.getValue());
        }

        if (getContext().getContextType() == BsonContextType.ARRAY || getContext().getContextType() == BsonContextType.DOCUMENT) {
            JsonToken commaToken = popToken();
            if (commaToken.getType() != JsonTokenType.COMMA) {
                pushToken(commaToken);
            }
        }

        switch (getContext().getContextType()) {
            case DOCUMENT:
            case SCOPE_DOCUMENT:
            default:
                setState(State.NAME);
                break;
            case ARRAY:
            case JAVASCRIPT_WITH_SCOPE:
            case TOP_LEVEL:
                setState(State.VALUE);
                break;
        }
        return getCurrentBsonType();
    }
    //CHECKSTYLE:ON

    @Override
    public Decimal128 doReadDecimal128() {
        return (Decimal128) currentValue;
    }

    @Override
    protected long doReadDateTime() {
        return (Long) currentValue;
    }

    @Override
    protected double doReadDouble() {
        return (Double) currentValue;
    }

    @Override
    protected void doReadEndArray() {
        setContext(getContext().getParentContext());

        if (getContext().getContextType() == BsonContextType.ARRAY || getContext().getContextType() == BsonContextType.DOCUMENT) {
            JsonToken commaToken = popToken();
            if (commaToken.getType() != JsonTokenType.COMMA) {
                pushToken(commaToken);
            }
        }
    }

    @Override
    protected void doReadEndDocument() {
        setContext(getContext().getParentContext());
        if (getContext() != null && getContext().getContextType() == BsonContextType.SCOPE_DOCUMENT) {
            setContext(getContext().getParentContext()); // JavaScriptWithScope
            verifyToken(JsonTokenType.END_OBJECT); // outermost closing bracket for JavaScriptWithScope
        }

        if (getContext() == null) {
            throw new JsonParseException("Unexpected end of document.");
        }

        if (getContext().getContextType() == BsonContextType.ARRAY || getContext().getContextType() == BsonContextType.DOCUMENT) {
            JsonToken commaToken = popToken();
            if (commaToken.getType() != JsonTokenType.COMMA) {
                pushToken(commaToken);
            }
        }
    }

    @Override
    protected int doReadInt32() {
        return (Integer) currentValue;
    }

    @Override
    protected long doReadInt64() {
        return (Long) currentValue;
    }

    @Override
    protected String doReadJavaScript() {
        return (String) currentValue;
    }

    @Override
    protected String doReadJavaScriptWithScope() {
        return (String) currentValue;
    }

    @Override
    protected void doReadMaxKey() {
    }

    @Override
    protected void doReadMinKey() {
    }

    @Override
    protected void doReadNull() {
    }

    @Override
    protected ObjectId doReadObjectId() {
        return (ObjectId) currentValue;
    }

    @Override
    protected BsonRegularExpression doReadRegularExpression() {
        return (BsonRegularExpression) currentValue;
    }

    @Override
    protected BsonDbPointer doReadDBPointer() {
        return (BsonDbPointer) currentValue;
    }

    @Override
    protected void doReadStartArray() {
        setContext(new Context(getContext(), BsonContextType.ARRAY));
    }

    @Override
    protected void doReadStartDocument() {
        setContext(new Context(getContext(), BsonContextType.DOCUMENT));
    }

    @Override
    protected String doReadString() {
        return (String) currentValue;
    }

    @Override
    protected String doReadSymbol() {
        return (String) currentValue;
    }

    @Override
    protected BsonTimestamp doReadTimestamp() {
        return (BsonTimestamp) currentValue;
    }

    @Override
    protected void doReadUndefined() {
    }

    @Override
    protected void doSkipName() {
    }

    @Override
    protected void doSkipValue() {
        switch (getCurrentBsonType()) {
            case ARRAY:
                readStartArray();
                while (readBsonType() != BsonType.END_OF_DOCUMENT) {
                    skipValue();
                }
                readEndArray();
                break;
            case BINARY:
                readBinaryData();
                break;
            case BOOLEAN:
                readBoolean();
                break;
            case DATE_TIME:
                readDateTime();
                break;
            case DOCUMENT:
                readStartDocument();
                while (readBsonType() != BsonType.END_OF_DOCUMENT) {
                    skipName();
                    skipValue();
                }
                readEndDocument();
                break;
            case DOUBLE:
                readDouble();
                break;
            case INT32:
                readInt32();
                break;
            case INT64:
                readInt64();
                break;
            case DECIMAL128:
                readDecimal128();
                break;
            case JAVASCRIPT:
                readJavaScript();
                break;
            case JAVASCRIPT_WITH_SCOPE:
                readJavaScriptWithScope();
                readStartDocument();
                while (readBsonType() != BsonType.END_OF_DOCUMENT) {
                    skipName();
                    skipValue();
                }
                readEndDocument();
                break;
            case MAX_KEY:
                readMaxKey();
                break;
            case MIN_KEY:
                readMinKey();
                break;
            case NULL:
                readNull();
                break;
            case OBJECT_ID:
                readObjectId();
                break;
            case REGULAR_EXPRESSION:
                readRegularExpression();
                break;
            case STRING:
                readString();
                break;
            case SYMBOL:
                readSymbol();
                break;
            case TIMESTAMP:
                readTimestamp();
                break;
            case UNDEFINED:
                readUndefined();
                break;
            default:
        }
    }

    private JsonToken popToken() {
        if (pushedToken != null) {
            JsonToken token = pushedToken;
            pushedToken = null;
            return token;
        } else {
            return scanner.nextToken();
        }
    }

    private void pushToken(final JsonToken token) {
        if (pushedToken == null) {
            pushedToken = token;
        } else {
            throw new BsonInvalidOperationException("There is already a pending token.");
        }
    }

    private void verifyToken(final JsonTokenType expectedType) {
        JsonToken token = popToken();
        if (expectedType != token.getType()) {
            throw new JsonParseException("JSON reader expected token type '%s' but found '%s'.", expectedType, token.getValue());
        }
    }

    private void verifyToken(final JsonTokenType expectedType, final Object expectedValue) {
        JsonToken token = popToken();
        if (expectedType != token.getType()) {
            throw new JsonParseException("JSON reader expected token type '%s' but found '%s'.", expectedType, token.getValue());
        }
        if (!expectedValue.equals(token.getValue())) {
            throw new JsonParseException("JSON reader expected '%s' but found '%s'.", expectedValue, token.getValue());
        }
    }

    private void verifyString(final String expected) {
        if (expected == null) {
            throw new IllegalArgumentException("Can't be null");
        }

        JsonToken token = popToken();
        JsonTokenType type = token.getType();

        if ((type != JsonTokenType.STRING && type != JsonTokenType.UNQUOTED_STRING) || !expected.equals(token.getValue())) {
            throw new JsonParseException("JSON reader expected '%s' but found '%s'.", expected, token.getValue());
        }
    }

    private void visitNew() {
        JsonToken typeToken = popToken();
        if (typeToken.getType() != JsonTokenType.UNQUOTED_STRING) {
            throw new JsonParseException("JSON reader expected a type name but found '%s'.", typeToken.getValue());
        }

        String value = typeToken.getValue(String.class);

        if ("MinKey".equals(value)) {
            visitEmptyConstructor();
            setCurrentBsonType(BsonType.MIN_KEY);
            currentValue = new MinKey();
        } else if ("MaxKey".equals(value)) {
            visitEmptyConstructor();
            setCurrentBsonType(BsonType.MAX_KEY);
            currentValue = new MaxKey();
        } else if ("BinData".equals(value)) {
            currentValue = visitBinDataConstructor();
            setCurrentBsonType(BsonType.BINARY);
        } else if ("Date".equals(value)) {
            currentValue = visitDateTimeConstructor();
            setCurrentBsonType(BsonType.DATE_TIME);
        } else if ("HexData".equals(value)) {
            currentValue = visitHexDataConstructor();
            setCurrentBsonType(BsonType.BINARY);
        } else if ("ISODate".equals(value)) {
            currentValue = visitISODateTimeConstructor();
            setCurrentBsonType(BsonType.DATE_TIME);
        } else if ("NumberInt".equals(value)) {
            currentValue = visitNumberIntConstructor();
            setCurrentBsonType(BsonType.INT32);
        } else if ("NumberLong".equals(value)) {
            currentValue = visitNumberLongConstructor();
            setCurrentBsonType(BsonType.INT64);
        } else if ("NumberDecimal".equals(value)) {
            currentValue = visitNumberDecimalConstructor();
            setCurrentBsonType(BsonType.DECIMAL128);
        } else if ("ObjectId".equals(value)) {
            currentValue = visitObjectIdConstructor();
            setCurrentBsonType(BsonType.OBJECT_ID);
        } else if ("RegExp".equals(value)) {
            currentValue = visitRegularExpressionConstructor();
            setCurrentBsonType(BsonType.REGULAR_EXPRESSION);
        } else if ("DBPointer".equals(value)) {
            currentValue = visitDBPointerConstructor();
            setCurrentBsonType(BsonType.DB_POINTER);
        } else if ("UUID".equals(value)
                   || "GUID".equals(value)
                   || "CSUUID".equals(value)
                   || "CSGUID".equals(value)
                   || "JUUID".equals(value)
                   || "JGUID".equals(value)
                   || "PYUUID".equals(value)
                   || "PYGUID".equals(value)) {
            currentValue = visitUUIDConstructor(value);
            setCurrentBsonType(BsonType.BINARY);
        } else {
            throw new JsonParseException("JSON reader expected a type name but found '%s'.", value);
        }
    }

    private void visitExtendedJSON() {
        JsonToken nameToken = popToken();
        String value = nameToken.getValue(String.class);
        JsonTokenType type = nameToken.getType();

        if (type == JsonTokenType.STRING || type == JsonTokenType.UNQUOTED_STRING) {

            if ("$binary".equals(value) || "$type".equals(value)) {
                currentValue = visitBinDataExtendedJson(value);
                if (currentValue != null) {
                    setCurrentBsonType(BsonType.BINARY);
                    return;
                }
            } else if ("$regex".equals(value) || "$options".equals(value)) {
                currentValue = visitRegularExpressionExtendedJson(value);
                if (currentValue != null) {
                    setCurrentBsonType(BsonType.REGULAR_EXPRESSION);
                    return;
                }
            } else if ("$code".equals(value)) {
                visitJavaScriptExtendedJson();
                return;
            } else if ("$date".equals(value)) {
                currentValue = visitDateTimeExtendedJson();
                setCurrentBsonType(BsonType.DATE_TIME);
                return;
            } else if ("$maxKey".equals(value)) {
                currentValue = visitMaxKeyExtendedJson();
                setCurrentBsonType(BsonType.MAX_KEY);
                return;
            } else if ("$minKey".equals(value)) {
                currentValue = visitMinKeyExtendedJson();
                setCurrentBsonType(BsonType.MIN_KEY);
                return;
            } else if ("$oid".equals(value)) {
                currentValue = visitObjectIdExtendedJson();
                setCurrentBsonType(BsonType.OBJECT_ID);
                return;
            } else if ("$regularExpression".equals(value)) {
                currentValue = visitNewRegularExpressionExtendedJson();
                setCurrentBsonType(BsonType.REGULAR_EXPRESSION);
                return;
            } else if ("$symbol".equals(value)) {
                currentValue = visitSymbolExtendedJson();
                setCurrentBsonType(BsonType.SYMBOL);
                return;
            } else if ("$timestamp".equals(value)) {
                currentValue = visitTimestampExtendedJson();
                setCurrentBsonType(BsonType.TIMESTAMP);
                return;
            } else if ("$undefined".equals(value)) {
                currentValue = visitUndefinedExtendedJson();
                setCurrentBsonType(BsonType.UNDEFINED);
                return;
            } else if ("$numberLong".equals(value)) {
                currentValue = visitNumberLongExtendedJson();
                setCurrentBsonType(BsonType.INT64);
                return;
            } else if ("$numberInt".equals(value)) {
                currentValue = visitNumberIntExtendedJson();
                setCurrentBsonType(BsonType.INT32);
                return;
            } else if ("$numberDouble".equals(value)) {
                currentValue = visitNumberDoubleExtendedJson();
                setCurrentBsonType(BsonType.DOUBLE);
                return;
            } else if ("$numberDecimal".equals(value)) {
                currentValue = visitNumberDecimalExtendedJson();
                setCurrentBsonType(BsonType.DECIMAL128);
                return;
            } else if ("$dbPointer".equals(value)) {
                currentValue = visitDbPointerExtendedJson();
                setCurrentBsonType(BsonType.DB_POINTER);
                return;
            }
        }

        pushToken(nameToken);
        setCurrentBsonType(BsonType.DOCUMENT);
    }

    private void visitEmptyConstructor() {
        JsonToken nextToken = popToken();
        if (nextToken.getType() == JsonTokenType.LEFT_PAREN) {
            verifyToken(JsonTokenType.RIGHT_PAREN);
        } else {
            pushToken(nextToken);
        }
    }

    private BsonBinary visitBinDataConstructor() {
        verifyToken(JsonTokenType.LEFT_PAREN);
        JsonToken subTypeToken = popToken();
        if (subTypeToken.getType() != JsonTokenType.INT32) {
            throw new JsonParseException("JSON reader expected a binary subtype but found '%s'.", subTypeToken.getValue());
        }
        verifyToken(JsonTokenType.COMMA);
        JsonToken bytesToken = popToken();
        if (bytesToken.getType() != JsonTokenType.UNQUOTED_STRING && bytesToken.getType() != JsonTokenType.STRING) {
            throw new JsonParseException("JSON reader expected a string but found '%s'.", bytesToken.getValue());
        }
        verifyToken(JsonTokenType.RIGHT_PAREN);

        byte[] bytes = Base64.decode(bytesToken.getValue(String.class));
        return new BsonBinary(subTypeToken.getValue(Integer.class).byteValue(), bytes);
    }

    private BsonBinary visitUUIDConstructor(final String uuidConstructorName) {
        verifyToken(JsonTokenType.LEFT_PAREN);
        String hexString = readStringFromExtendedJson().replaceAll("\\{", "").replaceAll("}", "").replaceAll("-", "");
        verifyToken(JsonTokenType.RIGHT_PAREN);
        byte[] bytes = decodeHex(hexString);
        BsonBinarySubType subType = BsonBinarySubType.UUID_STANDARD;
        if (!"UUID".equals(uuidConstructorName) || !"GUID".equals(uuidConstructorName)) {
            subType = BsonBinarySubType.UUID_LEGACY;
        }
        return new BsonBinary(subType, bytes);
    }

    private BsonRegularExpression visitRegularExpressionConstructor() {
        verifyToken(JsonTokenType.LEFT_PAREN);
        String pattern = readStringFromExtendedJson();
        String options = "";
        JsonToken commaToken = popToken();
        if (commaToken.getType() == JsonTokenType.COMMA) {
            options = readStringFromExtendedJson();
        } else {
            pushToken(commaToken);
        }
        verifyToken(JsonTokenType.RIGHT_PAREN);
        return new BsonRegularExpression(pattern, options);
    }

    private ObjectId visitObjectIdConstructor() {
        verifyToken(JsonTokenType.LEFT_PAREN);
        ObjectId objectId = new ObjectId(readStringFromExtendedJson());
        verifyToken(JsonTokenType.RIGHT_PAREN);
        return objectId;
    }

    private BsonTimestamp visitTimestampConstructor() {
        verifyToken(JsonTokenType.LEFT_PAREN);
        JsonToken timeToken = popToken();
        int time;
        if (timeToken.getType() != JsonTokenType.INT32) {
            throw new JsonParseException("JSON reader expected an integer but found '%s'.", timeToken.getValue());
        } else {
            time = timeToken.getValue(Integer.class);
        }
        verifyToken(JsonTokenType.COMMA);
        JsonToken incrementToken = popToken();
        int increment;
        if (incrementToken.getType() != JsonTokenType.INT32) {
            throw new JsonParseException("JSON reader expected an integer but found '%s'.", timeToken.getValue());
        } else {
            increment = incrementToken.getValue(Integer.class);
        }

        verifyToken(JsonTokenType.RIGHT_PAREN);
        return new BsonTimestamp(time, increment);
    }

    private BsonDbPointer visitDBPointerConstructor() {
        verifyToken(JsonTokenType.LEFT_PAREN);
        String namespace = readStringFromExtendedJson();
        verifyToken(JsonTokenType.COMMA);
        ObjectId id = new ObjectId(readStringFromExtendedJson());
        verifyToken(JsonTokenType.RIGHT_PAREN);
        return new BsonDbPointer(namespace, id);
    }

    private int visitNumberIntConstructor() {
        verifyToken(JsonTokenType.LEFT_PAREN);
        JsonToken valueToken = popToken();
        int value;
        if (valueToken.getType() == JsonTokenType.INT32) {
            value = valueToken.getValue(Integer.class);
        } else if (valueToken.getType() == JsonTokenType.STRING) {
            value = Integer.parseInt(valueToken.getValue(String.class));
        } else {
            throw new JsonParseException("JSON reader expected an integer or a string but found '%s'.", valueToken.getValue());
        }
        verifyToken(JsonTokenType.RIGHT_PAREN);
        return value;
    }

    private long visitNumberLongConstructor() {
        verifyToken(JsonTokenType.LEFT_PAREN);
        JsonToken valueToken = popToken();
        long value;
        if (valueToken.getType() == JsonTokenType.INT32 || valueToken.getType() == JsonTokenType.INT64) {
            value = valueToken.getValue(Long.class);
        } else if (valueToken.getType() == JsonTokenType.STRING) {
            value = Long.parseLong(valueToken.getValue(String.class));
        } else {
            throw new JsonParseException("JSON reader expected an integer or a string but found '%s'.", valueToken.getValue());
        }
        verifyToken(JsonTokenType.RIGHT_PAREN);
        return value;
    }

    private Decimal128 visitNumberDecimalConstructor() {
        verifyToken(JsonTokenType.LEFT_PAREN);
        JsonToken valueToken = popToken();
        Decimal128 value;
        if (valueToken.getType() == JsonTokenType.INT32 || valueToken.getType() == JsonTokenType.INT64
                    || valueToken.getType() == JsonTokenType.DOUBLE) {
            value = valueToken.getValue(Decimal128.class);
        } else if (valueToken.getType() == JsonTokenType.STRING) {
            value = Decimal128.parse(valueToken.getValue(String.class));
        } else {
            throw new JsonParseException("JSON reader expected a number or a string but found '%s'.", valueToken.getValue());
        }
        verifyToken(JsonTokenType.RIGHT_PAREN);
        return value;
    }

    private long visitISODateTimeConstructor() {
        verifyToken(JsonTokenType.LEFT_PAREN);

        JsonToken token = popToken();
        if (token.getType() == JsonTokenType.RIGHT_PAREN) {
            return new Date().getTime();
        } else if (token.getType() != JsonTokenType.STRING) {
            throw new JsonParseException("JSON reader expected a string but found '%s'.", token.getValue());
        }

        verifyToken(JsonTokenType.RIGHT_PAREN);
        String[] patterns = {"yyyy-MM-dd", "yyyy-MM-dd'T'HH:mm:ssz", "yyyy-MM-dd'T'HH:mm:ss.SSSz"};

        SimpleDateFormat format = new SimpleDateFormat(patterns[0], Locale.ENGLISH);
        ParsePosition pos = new ParsePosition(0);
        String s = token.getValue(String.class);

        if (s.endsWith("Z")) {
            s = s.substring(0, s.length() - 1) + "GMT-00:00";
        }

        for (final String pattern : patterns) {
            format.applyPattern(pattern);
            format.setLenient(true);
            pos.setIndex(0);

            Date date = format.parse(s, pos);

            if (date != null && pos.getIndex() == s.length()) {
                return date.getTime();
            }
        }
        throw new JsonParseException("Invalid date format.");
    }

    private BsonBinary visitHexDataConstructor() {
        verifyToken(JsonTokenType.LEFT_PAREN);
        JsonToken subTypeToken = popToken();
        if (subTypeToken.getType() != JsonTokenType.INT32) {
            throw new JsonParseException("JSON reader expected a binary subtype but found '%s'.", subTypeToken.getValue());
        }
        verifyToken(JsonTokenType.COMMA);
        String hex = readStringFromExtendedJson();
        verifyToken(JsonTokenType.RIGHT_PAREN);

        if ((hex.length() & 1) != 0) {
            hex = "0" + hex;
        }

        for (final BsonBinarySubType subType : BsonBinarySubType.values()) {
            if (subType.getValue() == subTypeToken.getValue(Integer.class)) {
                return new BsonBinary(subType, decodeHex(hex));
            }
        }
        return new BsonBinary(decodeHex(hex));
    }

    private long visitDateTimeConstructor() {
        DateFormat format = new SimpleDateFormat("EEE MMM dd yyyy HH:mm:ss z", Locale.ENGLISH);

        verifyToken(JsonTokenType.LEFT_PAREN);

        JsonToken token = popToken();
        if (token.getType() == JsonTokenType.RIGHT_PAREN) {
            return new Date().getTime();
        } else if (token.getType() == JsonTokenType.STRING) {
            verifyToken(JsonTokenType.RIGHT_PAREN);
            String s = token.getValue(String.class);
            ParsePosition pos = new ParsePosition(0);
            Date dateTime = format.parse(s, pos);
            if (dateTime != null && pos.getIndex() == s.length()) {
                return dateTime.getTime();
            } else {
                throw new JsonParseException("JSON reader expected a date in 'EEE MMM dd yyyy HH:mm:ss z' format but found '%s'.", s);
            }

        } else if (token.getType() == JsonTokenType.INT32 || token.getType() == JsonTokenType.INT64) {
            long[] values = new long[7];
            int pos = 0;
            while (true) {
                if (pos < values.length) {
                    values[pos++] = token.getValue(Long.class);
                }
                token = popToken();
                if (token.getType() == JsonTokenType.RIGHT_PAREN) {
                    break;
                }
                if (token.getType() != JsonTokenType.COMMA) {
                    throw new JsonParseException("JSON reader expected a ',' or a ')' but found '%s'.", token.getValue());
                }
                token = popToken();
                if (token.getType() != JsonTokenType.INT32 && token.getType() != JsonTokenType.INT64) {
                    throw new JsonParseException("JSON reader expected an integer but found '%s'.", token.getValue());
                }
            }
            if (pos == 1) {
                return values[0];
            } else if (pos < 3 || pos > 7) {
                throw new JsonParseException("JSON reader expected 1 or 3-7 integers but found %d.", pos);
            }

            Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
            calendar.set(Calendar.YEAR, (int) values[0]);
            calendar.set(Calendar.MONTH, (int) values[1]);
            calendar.set(Calendar.DAY_OF_MONTH, (int) values[2]);
            calendar.set(Calendar.HOUR_OF_DAY, (int) values[3]);
            calendar.set(Calendar.MINUTE, (int) values[4]);
            calendar.set(Calendar.SECOND, (int) values[5]);
            calendar.set(Calendar.MILLISECOND, (int) values[6]);
            return calendar.getTimeInMillis();
        } else {
            throw new JsonParseException("JSON reader expected an integer or a string but found '%s'.", token.getValue());
        }
    }

    private String visitDateTimeConstructorWithOutNew() {
        verifyToken(JsonTokenType.LEFT_PAREN);
        JsonToken token = popToken();
        if (token.getType() != JsonTokenType.RIGHT_PAREN) {
            while (token.getType() != JsonTokenType.END_OF_FILE) {
                token = popToken();
                if (token.getType() == JsonTokenType.RIGHT_PAREN) {
                    break;
                }
            }
            if (token.getType() != JsonTokenType.RIGHT_PAREN) {
                throw new JsonParseException("JSON reader expected a ')' but found '%s'.", token.getValue());
            }
        }

        DateFormat df = new SimpleDateFormat("EEE MMM dd yyyy HH:mm:ss z", Locale.ENGLISH);
        return df.format(new Date());
    }

    private BsonBinary visitBinDataExtendedJson(final String firstKey) {

        Mark mark = new Mark();

        try {
            verifyToken(JsonTokenType.COLON);

            if (firstKey.equals("$binary")) {
                JsonToken nextToken = popToken();
                if (nextToken.getType() == JsonTokenType.BEGIN_OBJECT) {
                    JsonToken nameToken = popToken();
                    String firstNestedKey = nameToken.getValue(String.class);
                    byte[] data;
                    byte type;
                    if (firstNestedKey.equals("base64")) {
                        verifyToken(JsonTokenType.COLON);
                        data = Base64.decode(readStringFromExtendedJson());
                        verifyToken(JsonTokenType.COMMA);
                        verifyString("subType");
                        verifyToken(JsonTokenType.COLON);
                        type = readBinarySubtypeFromExtendedJson();
                    } else if (firstNestedKey.equals("subType")) {
                        verifyToken(JsonTokenType.COLON);
                        type = readBinarySubtypeFromExtendedJson();
                        verifyToken(JsonTokenType.COMMA);
                        verifyString("base64");
                        verifyToken(JsonTokenType.COLON);
                        data = Base64.decode(readStringFromExtendedJson());
                    } else {
                        throw new JsonParseException("Unexpected key for $binary: " + firstNestedKey);
                    }
                    verifyToken(JsonTokenType.END_OBJECT);
                    verifyToken(JsonTokenType.END_OBJECT);
                    return new BsonBinary(type, data);
                } else {
                    mark.reset();
                    return visitLegacyBinaryExtendedJson(firstKey);
                }
            } else {
                mark.reset();
                return visitLegacyBinaryExtendedJson(firstKey);
            }
        } finally {
            mark.discard();
        }
    }

    private BsonBinary visitLegacyBinaryExtendedJson(final String firstKey) {

        Mark mark = new Mark();

        try {
            verifyToken(JsonTokenType.COLON);

            byte[] data;
            byte type;

            if (firstKey.equals("$binary")) {
                data = Base64.decode(readStringFromExtendedJson());
                verifyToken(JsonTokenType.COMMA);
                verifyString("$type");
                verifyToken(JsonTokenType.COLON);
                type = readBinarySubtypeFromExtendedJson();
            } else {
                type = readBinarySubtypeFromExtendedJson();
                verifyToken(JsonTokenType.COMMA);
                verifyString("$binary");
                verifyToken(JsonTokenType.COLON);
                data = Base64.decode(readStringFromExtendedJson());
            }
            verifyToken(JsonTokenType.END_OBJECT);

            return new BsonBinary(type, data);
        } catch (JsonParseException e) {
            mark.reset();
            return null;
        } catch (NumberFormatException e) {
            mark.reset();
            return null;
        } finally {
            mark.discard();
        }
    }

    private byte readBinarySubtypeFromExtendedJson() {
        JsonToken subTypeToken = popToken();
        if (subTypeToken.getType() != JsonTokenType.STRING && subTypeToken.getType() != JsonTokenType.INT32) {
            throw new JsonParseException("JSON reader expected a string or number but found '%s'.", subTypeToken.getValue());
        }

        if (subTypeToken.getType() == JsonTokenType.STRING) {
            return (byte) Integer.parseInt(subTypeToken.getValue(String.class), 16);
        } else {
            return subTypeToken.getValue(Integer.class).byteValue();
        }
    }

    private long visitDateTimeExtendedJson() {
        long value;
        verifyToken(JsonTokenType.COLON);
        JsonToken valueToken = popToken();
        if (valueToken.getType() == JsonTokenType.BEGIN_OBJECT) {
            JsonToken nameToken = popToken();
            String name = nameToken.getValue(String.class);
            if (!name.equals("$numberLong")) {
                throw new JsonParseException(String.format("JSON reader expected $numberLong within $date, but found %s", name));
            }
            value = visitNumberLongExtendedJson();
            verifyToken(JsonTokenType.END_OBJECT);
        } else {
            if (valueToken.getType() == JsonTokenType.INT32 || valueToken.getType() == JsonTokenType.INT64) {
                value = valueToken.getValue(Long.class);
            } else if (valueToken.getType() == JsonTokenType.STRING) {
                String dateTimeString = valueToken.getValue(String.class);
                try {
                    value = DateTimeFormatter.parse(dateTimeString);
                } catch (IllegalArgumentException e) {
                    throw new JsonParseException("Failed to parse string as a date", e);
                }
            } else {
                throw new JsonParseException("JSON reader expected an integer or string but found '%s'.", valueToken.getValue());
            }
            verifyToken(JsonTokenType.END_OBJECT);
        }
        return value;
    }

    private MaxKey visitMaxKeyExtendedJson() {
        verifyToken(JsonTokenType.COLON);
        verifyToken(JsonTokenType.INT32, 1);
        verifyToken(JsonTokenType.END_OBJECT);
        return new MaxKey();
    }

    private MinKey visitMinKeyExtendedJson() {
        verifyToken(JsonTokenType.COLON);
        verifyToken(JsonTokenType.INT32, 1);
        verifyToken(JsonTokenType.END_OBJECT);
        return new MinKey();
    }

    private ObjectId visitObjectIdExtendedJson() {
        verifyToken(JsonTokenType.COLON);
        ObjectId objectId = new ObjectId(readStringFromExtendedJson());
        verifyToken(JsonTokenType.END_OBJECT);
        return objectId;
    }

    private BsonRegularExpression visitNewRegularExpressionExtendedJson() {
        verifyToken(JsonTokenType.COLON);
        verifyToken(JsonTokenType.BEGIN_OBJECT);

        String pattern;
        String options = "";

        String firstKey = readStringFromExtendedJson();
        if (firstKey.equals("pattern")) {
            verifyToken(JsonTokenType.COLON);
            pattern = readStringFromExtendedJson();
            verifyToken(JsonTokenType.COMMA);
            verifyString("options");
            verifyToken(JsonTokenType.COLON);
            options = readStringFromExtendedJson();
        } else if (firstKey.equals("options")) {
            verifyToken(JsonTokenType.COLON);
            options = readStringFromExtendedJson();
            verifyToken(JsonTokenType.COMMA);
            verifyString("pattern");
            verifyToken(JsonTokenType.COLON);
            pattern = readStringFromExtendedJson();
        } else {
            throw new JsonParseException("Expected 't' and 'i' fields in $timestamp document but found " + firstKey);
        }

        verifyToken(JsonTokenType.END_OBJECT);
        verifyToken(JsonTokenType.END_OBJECT);
        return new BsonRegularExpression(pattern, options);
    }

    private BsonRegularExpression visitRegularExpressionExtendedJson(final String firstKey) {
        Mark extendedJsonMark = new Mark();

        try {
            verifyToken(JsonTokenType.COLON);

            String pattern;
            String options = "";
            if (firstKey.equals("$regex")) {
                pattern = readStringFromExtendedJson();
                verifyToken(JsonTokenType.COMMA);
                verifyString("$options");
                verifyToken(JsonTokenType.COLON);
                options = readStringFromExtendedJson();
            } else {
                options = readStringFromExtendedJson();
                verifyToken(JsonTokenType.COMMA);
                verifyString("$regex");
                verifyToken(JsonTokenType.COLON);
                pattern = readStringFromExtendedJson();
            }
            verifyToken(JsonTokenType.END_OBJECT);
            return new BsonRegularExpression(pattern, options);
        } catch (JsonParseException e) {
            extendedJsonMark.reset();
            return null;
        } finally {
            extendedJsonMark.discard();
        }
    }

    private String readStringFromExtendedJson() {
        JsonToken patternToken = popToken();
        if (patternToken.getType() != JsonTokenType.STRING) {
            throw new JsonParseException("JSON reader expected a string but found '%s'.", patternToken.getValue());
        }
        return patternToken.getValue(String.class);
    }

    private String visitSymbolExtendedJson() {
        verifyToken(JsonTokenType.COLON);
        String symbol = readStringFromExtendedJson();
        verifyToken(JsonTokenType.END_OBJECT);
        return symbol;
    }

    private BsonTimestamp visitTimestampExtendedJson() {
        verifyToken(JsonTokenType.COLON);
        verifyToken(JsonTokenType.BEGIN_OBJECT);

        int time;
        int increment;

        String firstKey = readStringFromExtendedJson();
        if (firstKey.equals("t")) {
            verifyToken(JsonTokenType.COLON);
            time = readIntFromExtendedJson();
            verifyToken(JsonTokenType.COMMA);
            verifyString("i");
            verifyToken(JsonTokenType.COLON);
            increment = readIntFromExtendedJson();
        } else if (firstKey.equals("i")) {
            verifyToken(JsonTokenType.COLON);
            increment = readIntFromExtendedJson();
            verifyToken(JsonTokenType.COMMA);
            verifyString("t");
            verifyToken(JsonTokenType.COLON);
            time = readIntFromExtendedJson();
        } else {
            throw new JsonParseException("Expected 't' and 'i' fields in $timestamp document but found " + firstKey);
        }

        verifyToken(JsonTokenType.END_OBJECT);
        verifyToken(JsonTokenType.END_OBJECT);
        return new BsonTimestamp(time, increment);
    }

    private int readIntFromExtendedJson() {
        JsonToken nextToken = popToken();
        int value;
        if (nextToken.getType() == JsonTokenType.INT32) {
            value = nextToken.getValue(Integer.class);
        } else if (nextToken.getType() == JsonTokenType.INT64) {
            value = nextToken.getValue(Long.class).intValue();
        } else {
            throw new JsonParseException("JSON reader expected an integer but found '%s'.", nextToken.getValue());
        }
        return value;
    }

    private void visitJavaScriptExtendedJson() {
        verifyToken(JsonTokenType.COLON);
        String code = readStringFromExtendedJson();
        JsonToken nextToken = popToken();
        switch (nextToken.getType()) {
            case COMMA:
                verifyString("$scope");
                verifyToken(JsonTokenType.COLON);
                setState(State.VALUE);
                currentValue = code;
                setCurrentBsonType(BsonType.JAVASCRIPT_WITH_SCOPE);
                setContext(new Context(getContext(), BsonContextType.SCOPE_DOCUMENT));
                break;
            case END_OBJECT:
                currentValue = code;
                setCurrentBsonType(BsonType.JAVASCRIPT);
                break;
            default:
                throw new JsonParseException("JSON reader expected ',' or '}' but found '%s'.", nextToken);
        }
    }

    private BsonUndefined visitUndefinedExtendedJson() {
        verifyToken(JsonTokenType.COLON);
        JsonToken valueToken = popToken();
        if (!valueToken.getValue(String.class).equals("true")) {
            throw new JsonParseException("JSON reader requires $undefined to have the value of true but found '%s'.",
                                                valueToken.getValue());
        }
        verifyToken(JsonTokenType.END_OBJECT);
        return new BsonUndefined();
    }

    private Long visitNumberLongExtendedJson() {
        verifyToken(JsonTokenType.COLON);
        Long value;
        String longAsString = readStringFromExtendedJson();
        try {
            value = Long.valueOf(longAsString);
        } catch (NumberFormatException e) {
            throw new JsonParseException(format("Exception converting value '%s' to type %s", longAsString, Long.class.getName()), e);
        }
        verifyToken(JsonTokenType.END_OBJECT);
        return value;
    }

    private Integer visitNumberIntExtendedJson() {
        verifyToken(JsonTokenType.COLON);
        Integer value;
        String intAsString = readStringFromExtendedJson();
        try {
            value = Integer.valueOf(intAsString);
        } catch (NumberFormatException e) {
            throw new JsonParseException(format("Exception converting value '%s' to type %s", intAsString, Integer.class.getName()), e);
        }
        verifyToken(JsonTokenType.END_OBJECT);
        return value;
    }

    private Double visitNumberDoubleExtendedJson() {
        verifyToken(JsonTokenType.COLON);
        Double value;
        String doubleAsString = readStringFromExtendedJson();
        try {
            value = Double.valueOf(doubleAsString);
        } catch (NumberFormatException e) {
            throw new JsonParseException(format("Exception converting value '%s' to type %s", doubleAsString, Double.class.getName()), e);
        }
        verifyToken(JsonTokenType.END_OBJECT);
        return value;
    }

    private Decimal128 visitNumberDecimalExtendedJson() {
        verifyToken(JsonTokenType.COLON);
        Decimal128 value;
        String decimal128AsString = readStringFromExtendedJson();
        try {
            value = Decimal128.parse(decimal128AsString);
        } catch (NumberFormatException e) {
            throw new JsonParseException(format("Exception converting value '%s' to type %s", decimal128AsString,
                    Decimal128.class.getName()), e);
        }
        verifyToken(JsonTokenType.END_OBJECT);
        return value;
    }

    private BsonDbPointer visitDbPointerExtendedJson() {
        verifyToken(JsonTokenType.COLON);
        verifyToken(JsonTokenType.BEGIN_OBJECT);

        String ref;
        ObjectId oid;

        String firstKey = readStringFromExtendedJson();
        if (firstKey.equals("$ref")) {
            verifyToken(JsonTokenType.COLON);
            ref = readStringFromExtendedJson();
            verifyToken(JsonTokenType.COMMA);
            verifyString("$id");
            oid = readDbPointerIdFromExtendedJson();
            verifyToken(JsonTokenType.END_OBJECT);
        } else if (firstKey.equals("$id")) {
            oid = readDbPointerIdFromExtendedJson();
            verifyToken(JsonTokenType.COMMA);
            verifyString("$ref");
            verifyToken(JsonTokenType.COLON);
            ref = readStringFromExtendedJson();

        } else {
            throw new JsonParseException("Expected $ref and $id fields in $dbPointer document but found " + firstKey);
        }
        verifyToken(JsonTokenType.END_OBJECT);
        return new BsonDbPointer(ref, oid);
    }

    private ObjectId readDbPointerIdFromExtendedJson() {
        ObjectId oid;
        verifyToken(JsonTokenType.COLON);
        verifyToken(JsonTokenType.BEGIN_OBJECT);
        verifyToken(JsonTokenType.STRING, "$oid");
        oid = visitObjectIdExtendedJson();
        return oid;
    }

    @Deprecated
    @Override
    public void mark() {
        if (mark != null) {
            throw new BSONException("A mark already exists; it needs to be reset before creating a new one");
        }
        mark = new Mark();
    }

    @Override
    public BsonReaderMark getMark() {
        return new Mark();
    }

    @Deprecated
    @Override
    public void reset() {
        if (mark == null) {
            throw new BSONException("trying to reset a mark before creating it");
        }
        mark.reset();
        mark = null;
    }

    @Override
    protected Context getContext() {
        return (Context) super.getContext();
    }

    protected class Mark extends AbstractBsonReader.Mark {
        private final JsonToken pushedToken;
        private final Object currentValue;
        private final int markPos;

        protected Mark() {
            super();
            pushedToken = JsonReader.this.pushedToken;
            currentValue = JsonReader.this.currentValue;
            markPos = JsonReader.this.scanner.mark();
        }

        public void reset() {
            super.reset();
            JsonReader.this.pushedToken = pushedToken;
            JsonReader.this.currentValue = currentValue;
            JsonReader.this.scanner.reset(markPos);
            JsonReader.this.setContext(new Context(getParentContext(), getContextType()));
        }

        public void discard() {
            JsonReader.this.scanner.discard(markPos);
        }
    }

    protected class Context extends AbstractBsonReader.Context {
        protected Context(final AbstractBsonReader.Context parentContext, final BsonContextType contextType) {
            super(parentContext, contextType);
        }

        protected Context getParentContext() {
            return (Context) super.getParentContext();
        }

        protected BsonContextType getContextType() {
            return super.getContextType();
        }
    }

    private static byte[] decodeHex(final String hex) {
        if (hex.length() % 2 != 0) {
            throw new IllegalArgumentException("A hex string must contain an even number of characters: " + hex);
        }

        byte[] out = new byte[hex.length() / 2];

        for (int i = 0; i < hex.length(); i += 2) {
            int high = Character.digit(hex.charAt(i), 16);
            int low = Character.digit(hex.charAt(i + 1), 16);
            if (high == -1 || low == -1) {
                throw new IllegalArgumentException("A hex string can only contain the characters 0-9, A-F, a-f: " + hex);
            }

            out[i / 2] = (byte) (high * 16 + low);
        }

        return out;
    }
}


/*
 * Copyright 2008-present 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.
 */

// WriteConcern.java

package com.mongodb;

import com.mongodb.annotations.Immutable;
import com.mongodb.lang.Nullable;
import org.bson.BsonBoolean;
import org.bson.BsonDocument;
import org.bson.BsonInt32;
import org.bson.BsonString;

import java.io.Serializable;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import static com.mongodb.assertions.Assertions.isTrue;
import static com.mongodb.assertions.Assertions.isTrueArgument;
import static com.mongodb.assertions.Assertions.notNull;

/**
 *
Controls the acknowledgment of write operations with various options.

 *
 *
{@code w}

 *

 *
 0: Don't wait for acknowledgement from the server

 *
 1: Wait for acknowledgement, but don't wait for secondaries to replicate

 *
 >=2: Wait for one or more secondaries to also acknowledge

 *
 "majority": Wait for a majority of data bearing nodes to acknowledge

 *
 "<tag set name>": Wait for one or more secondaries to also acknowledge based on a tag set name

 *

 *
{@code wtimeout} - how long to wait for secondaries to acknowledge before failing

 *

 *
0: indefinite

 *
>0: time to wait in milliseconds

 *

 *
 *
Other options:

 *

 *
{@code journal}: If true block until write operations have been committed to the journal. Cannot be used in combination with
 *   {@code fsync}. Prior to MongoDB 2.6 this option was ignored if the server was running without journaling.  Starting with MongoDB 2.6
 *   write operations will fail with an exception if this option is used when the server is running without journaling.

 *

 *
 * @mongodb.driver.manual core/write-concern Write Concern
 * @mongodb.driver.manual reference/write-concern/ Write Concern Reference
 */
@Immutable
public class WriteConcern implements Serializable {

    private static final long serialVersionUID = 1884671104750417011L;

    // map of the constants from above for use by fromString
    private static final Map<String, WriteConcern> NAMED_CONCERNS;

    private final Object w;

    private final Integer wTimeoutMS;

    private final Boolean fsync;

    private final Boolean journal;

    /**
     * Write operations that use this write concern will wait for acknowledgement, using the default write concern configured on the server.
     *
     * @since 2.10.0
     * @mongodb.driver.manual core/write-concern/#write-concern-acknowledged Acknowledged
     */
    public static final WriteConcern ACKNOWLEDGED = new WriteConcern((Object) null, null, null, null);

    /**
     * Write operations that use this write concern will wait for acknowledgement from a single member.
     *
     * @since 3.2
     * @mongodb.driver.manual reference/write-concern/#w-option w option
     */
    public static final WriteConcern W1 = new WriteConcern(1);

    /**
     * Write operations that use this write concern will wait for acknowledgement from two members.
     *
     * @since 3.2
     * @mongodb.driver.manual reference/write-concern/#w-option w option
     */
    public static final WriteConcern W2 = new WriteConcern(2);

    /**
     * Write operations that use this write concern will wait for acknowledgement from three members.
     *
     * @since 3.2
     * @mongodb.driver.manual reference/write-concern/#w-option w option
     */
    public static final WriteConcern W3 = new WriteConcern(3);

    /**
     * Write operations that use this write concern will return as soon as the message is written to the socket. Exceptions are raised for
     * network issues, but not server errors.
     *
     * @since 2.10.0
     * @mongodb.driver.manual core/write-concern/#unacknowledged Unacknowledged
     */
    public static final WriteConcern UNACKNOWLEDGED = new WriteConcern(0);

    /**
     * Write operations wait for the server to flush the data to disk.
     *
     * @deprecated Prefer {@link #JOURNALED}
     */
    @Deprecated
    public static final WriteConcern FSYNCED = ACKNOWLEDGED.withFsync(true);

    /**
     * Write operations wait for the server to group commit to the journal file on disk.
     *
     * @mongodb.driver.manual core/write-concern/#journaled Journaled
     */
    public static final WriteConcern JOURNALED = ACKNOWLEDGED.withJournal(true);

    /**
     * Exceptions are raised for network issues, and server errors; waits for at least 2 servers for the write operation.
     * @deprecated Prefer WriteConcern#W2
     */
    @Deprecated
    public static final WriteConcern REPLICA_ACKNOWLEDGED = new WriteConcern(2);

    /**
     *
Write operations that use this write concern will return as soon as the message is written to the socket. Exceptions are raised
     * for network issues, but not server errors.

     *
     *
This field has been superseded by {@code WriteConcern.UNACKNOWLEDGED}, and may be deprecated in a future release.

     *
     * @see WriteConcern#UNACKNOWLEDGED
     * @deprecated Prefer {@link #UNACKNOWLEDGED}
     */
    @Deprecated
    public static final WriteConcern NORMAL = UNACKNOWLEDGED;

    /**
     *
Write operations that use this write concern will wait for acknowledgement from the primary server before returning. Exceptions
     * are raised for network issues, and server errors.

     *
     *
This field has been superseded by {@code WriteConcern.ACKNOWLEDGED}, and may be deprecated in a future release.

     *
     * @see WriteConcern#ACKNOWLEDGED
     * @deprecated Prefer {@link #ACKNOWLEDGED}
     */
    @Deprecated
    public static final WriteConcern SAFE = ACKNOWLEDGED;

    /**
     * Exceptions are raised for network issues, and server errors; waits on a majority of servers for the write operation.
     */
    public static final WriteConcern MAJORITY = new WriteConcern("majority");

    /**
     *
Exceptions are raised for network issues, and server errors; the write operation waits for the server to flush the data to
     * disk.

     *
     *
This field has been superseded by {@code WriteConcern.FSYNCED}, and may be deprecated in a future release.

     *
     * @see WriteConcern#FSYNCED
     * @deprecated Prefer {@link #JOURNALED}
     */
    @Deprecated
    public static final WriteConcern FSYNC_SAFE = FSYNCED;

    /**
     *
Exceptions are raised for network issues, and server errors; the write operation waits for the server to group commit to the
     * journal file on disk.

     *
     *
This field has been superseded by {@code WriteConcern.JOURNALED}, and may be deprecated in a future release.

     *
     * @deprecated Prefer {@link #JOURNALED}
     */
    @Deprecated
    public static final WriteConcern JOURNAL_SAFE = JOURNALED;

    /**
     *
Exceptions are raised for network issues, and server errors; waits for at least 2 servers for the write operation.

     *
     *
This field has been superseded by {@code WriteConcern.REPLICA_ACKNOWLEDGED}, and may be deprecated in a future release.

     *
     * @see WriteConcern#W2
     * @deprecated Prefer {@link #W2}
     */
    @Deprecated
    public static final WriteConcern REPLICAS_SAFE = REPLICA_ACKNOWLEDGED;

    /**
     * Constructs an instance with all properties initialized to their default values, except for w, which is initialized to 0, making
     * instances constructed this ways equivalent to {@code WriteConcern#UNACKNOWLEDGED}
     *
     * @see WriteConcern#UNACKNOWLEDGED
     * @deprecated Prefer {@link #UNACKNOWLEDGED}
     */
    @Deprecated
    public WriteConcern() {
        this(0);
    }

    /**
     * Construct an instance with the given integer-based value for w.
     *
     * @param w number of servers to ensure write propagation to before acknowledgment, which must be {@code >= 0}
     * @mongodb.driver.manual reference/write-concern/#w-option w option
     */
    public WriteConcern(final int w) {
        this(w, null, null, null);
    }

    /**
     * Construct an instance with the given tag set-based value for w.
     *
     * @param w tag set name, or "majority", representing the servers to ensure write propagation to before acknowledgment.  Do not use
     *          string representation of integer values for w
     * @mongodb.driver.manual tutorial/configure-replica-set-tag-sets/#replica-set-configuration-tag-sets Tag Sets
     * @mongodb.driver.manual reference/write-concern/#w-option w option
     */
    public WriteConcern(final String w) {
        this(w, null, null, null);
        notNull("w", w);
    }

    /**
     * Constructs an instance with the given integer-based value for w and the given value for wTimeoutMS.
     *
     * @param w          the w value, which must be >= 0
     * @param wTimeoutMS the wTimeout in milliseconds, which must be >= 0
     * @mongodb.driver.manual reference/write-concern/#w-option w option
     * @mongodb.driver.manual reference/write-concern/#wtimeout wtimeout option
     */
    public WriteConcern(final int w, final int wTimeoutMS) {
        this(w, wTimeoutMS, null, null);
    }

    /**
     * Constructs an instance with the given value for fsync.
     *
     * @param fsync whether or not to fsync
     * @deprecated prefer {@link #JOURNALED} or {@link #withJournal(Boolean)}
     */
    @Deprecated
    public WriteConcern(final boolean fsync) {
        this(null, null, fsync, null);
    }

    /**
     * Constructs an instance with the given integer-based w value, wTimeout in milliseconds, and fsync value.
     *
     * @param w          the w value, which must be >= 0
     * @param wTimeoutMS the wTimeout in milliseconds, which must be >= 0
     * @param fsync      whether or not to fsync
     * @deprecated Prefer {@link #withW(int)}, {@link #withWTimeout(long, TimeUnit)}, {@link #withJournal(Boolean)}
     * @mongodb.driver.manual reference/write-concern/#w-option w option
     * @mongodb.driver.manual reference/write-concern/#wtimeout wtimeout option
     */
    @Deprecated
    public WriteConcern(final int w, final int wTimeoutMS, final boolean fsync) {
        this(w, wTimeoutMS, fsync, null);
    }

    /**
     * Constructs an instance with the given integer-based w value, wTimeout in milliseconds, fsync value, and journal value.
     *
     * @param w          the w value, which must be >= 0
     * @param wTimeoutMS the wTimeout in milliseconds, which must be >= 0
     * @param fsync      whether or not to fsync
     * @param journal    whether writes should wait for a journaling group commit
     * @deprecated Prefer {@link #withW(int)}, {@link #withWTimeout(long, TimeUnit)}, {@link #withJournal(Boolean)}
     * @mongodb.driver.manual reference/write-concern/#w-option w option
     * @mongodb.driver.manual reference/write-concern/#wtimeout wtimeout option
     * @mongodb.driver.manual reference/write-concern/#j-option j option
     */
    @Deprecated
    public WriteConcern(final int w, final int wTimeoutMS, final boolean fsync, final boolean journal) {
        this((Object) w, wTimeoutMS, fsync, journal);
    }

    /**
     * Constructs an instance with the given String-based w value, wTimeout in milliseconds, fsync value, and journal value.
     *
     * @param w          the w value, which must be non-null
     * @param wTimeoutMS the wTimeout in milliseconds, which must be >= 0
     * @param fsync      whether or not to fsync
     * @param journal    whether writes should wait for a journaling group commit
     * @deprecated Prefer {@link #withW(String)}, {@link #withWTimeout(long, TimeUnit)}, {@link #withJournal(Boolean)}
     */
    @Deprecated
    public WriteConcern(final String w, final int wTimeoutMS, final boolean fsync, final boolean journal) {
        this((Object) notNull("w", w), wTimeoutMS, fsync, journal);
    }

    // Private constructor for creating the "default" unacknowledged write concern.  Necessary because there already a no-args
    // constructor that means something else.
    private WriteConcern(@Nullable final Object w, @Nullable final Integer wTimeoutMS, @Nullable final Boolean fsync,
                         @Nullable final Boolean journal) {
        if (w instanceof Integer) {
            isTrueArgument("w >= 0", ((Integer) w) >= 0);
            if ((Integer) w == 0) {
                isTrueArgument("fsync is false when w is 0", fsync == null || !fsync);
                isTrueArgument("journal is false when w is 0", journal == null || !journal);
            }
        } else if (w != null) {
            isTrueArgument("w must be String or int", w instanceof String);
        }
        isTrueArgument("wtimeout >= 0", wTimeoutMS == null || wTimeoutMS >= 0);
        this.w = w;
        this.wTimeoutMS = wTimeoutMS;
        this.fsync = fsync;
        this.journal = journal;
    }

    /**
     * Gets the w value.
     *
     * @return w, either an instance of Integer or String or null
     */
    public Object getWObject() {
        return w;
    }

    /**
     * Gets the w value as an integer.
     *
     * @return w as an int
     * @throws IllegalStateException if w is null or not an integer
     */
    public int getW() {
        isTrue("w is an Integer", w != null && w instanceof Integer);
        return (Integer) w;
    }

    /**
     * Gets the w parameter as a String.
     *
     * @return w as a String
     * @throws IllegalStateException if w is null or not a String
     */
    public String getWString() {
        isTrue("w is a String", w != null && w instanceof String);
        return (String) w;
    }

    /**
     * Gets the wTimeout in the given time unit.
     *
     * @param timeUnit the non-null time unit for the result
     * @return the WTimeout, which may be null if a wTimeout has not been specified
     * @since 3.2
     * @mongodb.driver.manual core/write-concern/#timeouts wTimeout
     */
    @Nullable
    public Integer getWTimeout(final TimeUnit timeUnit) {
        notNull("timeUnit", timeUnit);
        return wTimeoutMS == null ? null : (int) timeUnit.convert(wTimeoutMS, TimeUnit.MILLISECONDS);
    }

    /**
     * Gets the wTimeout in milliseconds.  If wTimeout has not been specified, returns 0.
     *
     * @return the wTimeout in milliseconds
     * @deprecated Prefer {@link #getWTimeout(TimeUnit)}
     * @mongodb.driver.manual core/write-concern/#timeouts wTimeout
     */
    @Deprecated
    public int getWtimeout() {
        return wTimeoutMS == null ? 0 : wTimeoutMS;
    }

    /**
     * Gets the journal property.  The default value is null.
     *
     * @return whether journal syncing is enabled, or null if unspecified.
     * @since 3.2
     * @mongodb.driver.manual core/write-concern/#journaled Journaled
     */
    public Boolean getJournal() {
        return journal;
    }

    /**
     * Gets the journal property.  If journal has not been specified, returns false.
     *
     * @return true if journal is true, false if it false or unspecified.
     * @mongodb.driver.manual core/write-concern/#journaled Journaled
     * @deprecated Prefer {@link #getJournal()}
     */
    @Deprecated
    public boolean getJ() {
        return journal == null ? false : journal;
    }

    /**
     * Gets the fsync property.  If fsync has not been specified, returns false.
     *
     * @return true if fsync is true, false if it false or unspecified
     * @deprecated Prefer {@link #getJournal()}
     */
    @Deprecated
    public boolean getFsync() {
        return fsync == null ? false : fsync;
    }

    /**
     * Gets the fsync property. If fsync has not been specified, returns false.
     *
     * @return true if fsync is true, false if it false or unspecified
     * @deprecated Prefer {@link #getJournal()}
     */
    @Deprecated
    public boolean fsync() {
        return getFsync();
    }

    /**
     * Returns true if this write concern indicates that write operations must be acknowledged.
     *
     * @return whether this write concern will result in an an acknowledged write
     * @deprecated Prefer {@link #isAcknowledged()}
     */
    @Deprecated
    public boolean callGetLastError() {
        return isAcknowledged();
    }

    /**
     * Gets whether this write concern indicates that the server's default write concern will be used.
     *
     * @return true if this write concern indicates that the server's default write concern will be used
     * @mongodb.driver.manual /reference/replica-configuration/#local.system.replset.settings.getLastErrorDefaults getLastErrorDefaults
     */
    public boolean isServerDefault() {
        return equals(ACKNOWLEDGED);
    }

    /**
     * Gets this write concern as a document.
     *
     * @return The write concern as a BsonDocument, even if {@code w <= 0}
     */
    public BsonDocument asDocument() {
        BsonDocument document = new BsonDocument();

        addW(document);

        addWTimeout(document);
        addFSync(document);
        addJ(document);

        return document;
    }

    /**
     * Returns true if this write concern indicates that write operations must be acknowledged.
     *
     * @return true w != null or w > 0 or journal is true or fsync is true
     * @mongodb.driver.manual core/write-concern/#acknowledged Acknowledged
     */
    public boolean isAcknowledged() {
        if (w instanceof Integer) {
            return (Integer) w > 0 || (journal != null && journal) || (fsync != null && fsync);
        }
        return true;
    }

    /**
     * Gets the WriteConcern constants by name (matching is done case insensitively).
     *
     * @param name the name of the WriteConcern
     * @return the {@code WriteConcern instance}
     */
    public static WriteConcern valueOf(final String name) {
        return NAMED_CONCERNS.get(name.toLowerCase());
    }

    @Override
    public boolean equals(final Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }

        WriteConcern that = (WriteConcern) o;

        if (w != null ? !w.equals(that.w) : that.w != null) {
            return false;
        }
        if (wTimeoutMS != null ? !wTimeoutMS.equals(that.wTimeoutMS) : that.wTimeoutMS != null) {
            return false;
        }
        if (fsync != null ? !fsync.equals(that.fsync) : that.fsync != null) {
            return false;
        }
        if (journal != null ? !journal.equals(that.journal) : that.journal != null) {
            return false;
        }

        return true;
    }

    @Override
    public int hashCode() {
        int result = w != null ? w.hashCode() : 0;
        result = 31 * result + (wTimeoutMS != null ? wTimeoutMS.hashCode() : 0);
        result = 31 * result + (fsync != null ? fsync.hashCode() : 0);
        result = 31 * result + (journal != null ? journal.hashCode() : 0);
        return result;
    }

    @Override
    public String toString() {
        return "WriteConcern{w=" + w + ", wTimeout=" + wTimeoutMS + " ms, fsync=" + fsync + ", journal=" + journal;

    }

    /**
     * Constructs a new WriteConcern from the current one and the specified integer-based value for w
     *
     * @param w number of servers to ensure write propagation to before acknowledgment, which must be {@code >= 0}
     * @return the new WriteConcern
     * @mongodb.driver.manual core/write-concern/#replica-acknowledged Replica Acknowledged
     */
    public WriteConcern withW(final int w) {
        return new WriteConcern(Integer.valueOf(w), wTimeoutMS, fsync, journal);
    }

    /**
     * Constructs a new WriteConcern from the current one and the specified tag-set based value for w
     *
     * @param w tag set, or "majority", representing the servers to ensure write propagation to before acknowledgment.  Do not use string
     *          representation of integer values for w
     * @return the new WriteConcern
     * @see #withW(int)
     * @mongodb.driver.manual tutorial/configure-replica-set-tag-sets/#replica-set-configuration-tag-sets Tag Sets
     */
    public WriteConcern withW(final String w) {
        notNull("w", w);
        return new WriteConcern(w, wTimeoutMS, fsync, journal);
    }

    /**
     * Constructs a new WriteConcern from the current one and the specified fsync value
     *
     * @param fsync true if the write concern needs to include fsync
     * @return the new WriteConcern
     * @deprecated Prefer {@link #withJournal(Boolean)}
     */
    @Deprecated
    public WriteConcern withFsync(final boolean fsync) {
        return new WriteConcern(w, wTimeoutMS, fsync, journal);
    }

    /**
     * Constructs a new WriteConcern from the current one and the specified journal value
     *
     * @param journal true if journalling is required for acknowledgement, false if not, or null if unspecified
     * @return the new WriteConcern
     * @since 3.2
     * @mongodb.driver.manual reference/write-concern/#j-option j option
     */
    public WriteConcern withJournal(final Boolean journal) {
        return new WriteConcern(w, wTimeoutMS, fsync, journal);
    }

    /**
     * Constructs a new WriteConcern from the current one and the specified journal value
     *
     * @param journal true if journalling is required for acknowledgement
     * @return the new WriteConcern
     * @deprecated Prefer {@link #withJournal(Boolean)}
     * @mongodb.driver.manual reference/write-concern/#j-option j option
     */
    @Deprecated
    public WriteConcern withJ(final boolean journal) {
        return withJournal(journal);
    }

    /**
     * Constructs a new WriteConcern from the current one and the specified wTimeout in the given time unit.
     *
     * @param wTimeout the wTimeout, which must be >= 0 and <= Integer.MAX_VALUE after conversion to milliseconds
     * @param timeUnit the non-null time unit to apply to wTimeout
     * @return the WriteConcern with the given wTimeout
     * @since 3.2
     * @mongodb.driver.manual reference/write-concern/#wtimeout wtimeout option
     */
    public WriteConcern withWTimeout(final long wTimeout, final TimeUnit timeUnit) {
        notNull("timeUnit", timeUnit);
        long newWTimeOutMS = TimeUnit.MILLISECONDS.convert(wTimeout, timeUnit);
        isTrueArgument("wTimeout >= 0", wTimeout >= 0);
        isTrueArgument("wTimeout <= " + Integer.MAX_VALUE + " ms", newWTimeOutMS <= Integer.MAX_VALUE);
        return new WriteConcern(w, (int) newWTimeOutMS, fsync, journal);
    }

    private void addW(final BsonDocument document) {
        if (w instanceof String) {
            document.put("w", new BsonString((String) w));
        } else if (w instanceof Integer){
            document.put("w", new BsonInt32((Integer) w));
        }
    }

    private void addJ(final BsonDocument document) {
        if (journal != null) {
            document.put("j", BsonBoolean.valueOf(journal));
        }
    }

    private void addFSync(final BsonDocument document) {
        if (fsync != null) {
            document.put("fsync", BsonBoolean.valueOf(fsync));
        }
    }

    private void addWTimeout(final BsonDocument document) {
        if (wTimeoutMS != null) {
            document.put("wtimeout", new BsonInt32(wTimeoutMS));
        }
    }

    /**
     * Create a Majority Write Concern that requires a majority of servers to acknowledge the write.
     *
     * @param wtimeout timeout for write operation
     * @param fsync    whether or not to fsync
     * @param j        whether writes should wait for a journal group commit
     * @return Majority, a subclass of WriteConcern that represents the write concern requiring most servers to acknowledge the write
     * @deprecated Prefer {@link #MAJORITY}, {@link #withWTimeout(long, TimeUnit)}, {@link #withJournal(Boolean)}
     */
    @Deprecated
    public static Majority majorityWriteConcern(final int wtimeout, final boolean fsync, final boolean j) {
        return new Majority(wtimeout, fsync, j);
    }

    /**
     * A write concern that blocks acknowledgement of a write operation until a majority of replica set members have applied it.
     *
     * @deprecated Prefer {@link #MAJORITY}, {@link #withWTimeout(long, TimeUnit)}, {@link #withJournal(Boolean)}
     */
    @Deprecated
    public static class Majority extends WriteConcern {

        private static final long serialVersionUID = -4128295115883875212L;

        /**
         * Create a new Majority WriteConcern.
         */
        public Majority() {
            this(0, false, false);
        }

        /**
         * Create a new WriteConcern with the given configuration.
         *
         * @param wtimeout timeout for write operation
         * @param fsync    whether or not to fsync
         * @param j        whether writes should wait for a journaling group commit
         */
        public Majority(final int wtimeout, final boolean fsync, final boolean j) {
            super("majority", wtimeout, fsync, j);
        }
    }

    static {
        NAMED_CONCERNS = new HashMap<String, WriteConcern>();
        for (final Field f : WriteConcern.class.getFields()) {
            if (Modifier.isStatic(f.getModifiers()) && f.getType().equals(WriteConcern.class)) {
                String key = f.getName().toLowerCase();
                try {
                    NAMED_CONCERNS.put(key, (WriteConcern) f.get(null));
                } catch (IllegalAccessException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
}


/*
 * Copyright 2008-present 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 org.bson.types.ObjectId;

import java.util.ArrayList;
import java.util.List;

import static com.mongodb.assertions.Assertions.isTrue;

/**
 * A bulk write operation.  A bulk write operation consists of an ordered or unordered collection of write requests,
 * which can be any combination of inserts, updates, replaces, or removes.
 *
 * @see DBCollection#initializeOrderedBulkOperation()
 * @see com.mongodb.DBCollection#initializeUnorderedBulkOperation()
 *
 * @mongodb.server.release 2.6
 * @mongodb.driver.manual /reference/command/delete/ Delete
 * @mongodb.driver.manual /reference/command/update/ Update
 * @mongodb.driver.manual /reference/command/insert/ Insert
 * @since 2.12
 */
public class BulkWriteOperation {
    private static final String ID_FIELD_NAME = "_id";
    private final boolean ordered;
    private final DBCollection collection;
    private final List<WriteRequest> requests = new ArrayList<WriteRequest>();
    private Boolean bypassDocumentValidation;
    private boolean closed;

    BulkWriteOperation(final boolean ordered, final DBCollection collection) {
        this.ordered = ordered;
        this.collection = collection;
    }

    /**
     * Returns true if this is building an ordered bulk write request.
     *
     * @return whether this is building an ordered bulk write operation
     * @see DBCollection#initializeOrderedBulkOperation()
     * @see DBCollection#initializeUnorderedBulkOperation()
     */
    public boolean isOrdered() {
        return ordered;
    }

    /**
     * Gets whether to bypass document validation, or null if unspecified.  The default is null.
     *
     * @return whether to bypass document validation, or null if unspecified.
     * @since 2.14
     * @mongodb.server.release 3.2
     */
    public Boolean getBypassDocumentValidation() {
        return bypassDocumentValidation;
    }

    /**
     * Sets whether to bypass document validation.
     *
     * @param bypassDocumentValidation whether to bypass document validation, or null if unspecified
     * @since 2.14
     * @mongodb.server.release 3.2
     */
    public void setBypassDocumentValidation(final Boolean bypassDocumentValidation) {
        this.bypassDocumentValidation = bypassDocumentValidation;
    }

    /**
     * Add an insert request to the bulk operation
     *
     * @param document the document to insert
     */
    public void insert(final DBObject document) {
        isTrue("already executed", !closed);
        if (document.get(ID_FIELD_NAME) == null) {
            document.put(ID_FIELD_NAME, new ObjectId());
        }
        addRequest(new InsertRequest(document, collection.getObjectCodec()));
    }

    /**
     * Start building a write request to add to the bulk write operation.  The returned builder can be used to create an update, replace,
     * or remove request with the given query.
     *
     * @param query the query for an update, replace or remove request
     * @return a builder for a single write request
     */
    public BulkWriteRequestBuilder find(final DBObject query) {
        isTrue("already executed", !closed);
        return new BulkWriteRequestBuilder(this, query, collection.getDefaultDBObjectCodec(), collection.getObjectCodec());
    }

    /**
     * Execute the bulk write operation with the default write concern of the collection from which this came.  Note that the
     * continueOnError property of the write concern is ignored.
     *
     * @return the result of the bulk write operation.
     * @throws com.mongodb.BulkWriteException if the write failed due some other failure specific to the write command
     * @throws MongoException if the operation failed for some other reason
     */
    public BulkWriteResult execute() {
        isTrue("already executed", !closed);
        closed = true;
        return collection.executeBulkWriteOperation(ordered, bypassDocumentValidation, requests);
    }

    /**
     * Execute the bulk write operation with the given write concern.  Note that the continueOnError property of the write concern is
     * ignored.
     *
     * @param writeConcern the write concern to apply to the bulk operation.
     * @return the result of the bulk write operation.
     * @throws com.mongodb.BulkWriteException if the write failed due some other failure specific to the write command
     * @throws MongoException if the operation failed for some other reason
     */
    public BulkWriteResult execute(final WriteConcern writeConcern) {
        isTrue("already executed", !closed);
        closed = true;
        return collection.executeBulkWriteOperation(ordered, bypassDocumentValidation, requests, writeConcern);
    }

    void addRequest(final WriteRequest request) {
        isTrue("already executed", !closed);
        requests.add(request);
    }
}


/*
 * Copyright 2008-present 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 org.bson.BSONObject;
import org.bson.BasicBSONObject;
import org.bson.BsonBinaryWriter;
import org.bson.BsonDocument;
import org.bson.BsonDocumentWrapper;
import org.bson.codecs.Decoder;
import org.bson.codecs.DecoderContext;
import org.bson.codecs.Encoder;
import org.bson.codecs.EncoderContext;
import org.bson.codecs.configuration.CodecRegistry;
import org.bson.conversions.Bson;
import org.bson.io.BasicOutputBuffer;
import org.bson.io.OutputBuffer;
import org.bson.json.JsonReader;
import org.bson.json.JsonWriter;
import org.bson.json.JsonWriterSettings;
import org.bson.types.BasicBSONList;

import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;

/**
 * A basic implementation of BSON object that is MongoDB specific. A {@code DBObject} can be created as follows, using this class:
 *

 * DBObject obj = new BasicDBObject();
 * obj.put( "foo", "bar" );
 *

 *
 * @mongodb.driver.manual core/document/ MongoDB Documents
 */
@SuppressWarnings({"rawtypes"})
public class BasicDBObject extends BasicBSONObject implements DBObject, Bson {
    private static final long serialVersionUID = -4415279469780082174L;

    private boolean isPartialObject;

    /**
     * Parses a string in MongoDB Extended JSON format to a {@code BasicDBObject}.
     *
     * @param json the JSON string
     * @return a corresponding {@code BasicDBObject} object
     * @see org.bson.json.JsonReader
     * @mongodb.driver.manual reference/mongodb-extended-json/ MongoDB Extended JSON
     */
    public static BasicDBObject parse(final String json) {
        return parse(json, DBObjectCodec.getDefaultRegistry().get(BasicDBObject.class));
    }

    /**
     * Parses a string in MongoDB Extended JSON format to a {@code BasicDBObject}.
     *
     * @param json the JSON string
     * @param decoder the decoder to use to decode the BasicDBObject instance
     * @return a corresponding {@code BasicDBObject} object
     * @see org.bson.json.JsonReader
     * @mongodb.driver.manual reference/mongodb-extended-json/ MongoDB Extended JSON
     */
    public static BasicDBObject parse(final String json, final Decoder<BasicDBObject> decoder) {
        return decoder.decode(new JsonReader(json), DecoderContext.builder().build());
    }

    /**
     * Creates an empty object.
     */
    public BasicDBObject() {
        super();
    }

    /**
     * Creates an empty object
     *
     * @param size an estimate of number of fields that will be inserted
     */
    public BasicDBObject(final int size) {
        super(size);
    }

    /**
     * Creates an object with the given key/value
     *
     * @param key   key under which to store
     * @param value value to store
     */
    public BasicDBObject(final String key, final Object value) {
        super(key, value);
    }

    /**
     * Creates an object from a map.
     *
     * @param map map to convert
     */
    public BasicDBObject(final Map map) {
        super(map);
    }

    /**
     * Add a key/value pair to this object
     *
     * @param key the field name
     * @param val the field value
     * @return this BasicDBObject with the new values added
     */
    @Override
    public BasicDBObject append(final String key, final Object val) {
        put(key, val);
        return this;
    }

    /**
     * Whether {@link #markAsPartialObject} was ever called only matters if you are going to upsert and do not want to risk losing fields.
     *
     * @return true if this has been marked as a partial object
     */
    @Override
    public boolean isPartialObject() {
        return isPartialObject;
    }

    /**
     * Gets a JSON representation of this document using the {@link org.bson.json.JsonMode#STRICT} output mode, and otherwise the default
     * settings of {@link JsonWriterSettings.Builder} and {@link DBObjectCodec}.
     *
     * @return a JSON representation of this document
     * @throws org.bson.codecs.configuration.CodecConfigurationException if the document contains types not in the default registry
     * @see #toJson(JsonWriterSettings)
     * @see JsonWriterSettings
     */
    @SuppressWarnings("deprecation")
    public String toJson() {
        return toJson(new JsonWriterSettings());
    }

    /**
     * Gets a JSON representation of this document
     *
     *
With the default {@link DBObjectCodec}.

     *
     * @param writerSettings the json writer settings to use when encoding
     * @return a JSON representation of this document
     * @throws org.bson.codecs.configuration.CodecConfigurationException if the document contains types not in the default registry
     */
    public String toJson(final JsonWriterSettings writerSettings) {
        return toJson(writerSettings, DBObjectCodec.getDefaultRegistry().get(BasicDBObject.class));
    }

    /**
     * Gets a JSON representation of this document
     *
     *
With the default {@link JsonWriterSettings}.

     *
     * @param encoder the BasicDBObject codec instance to encode the document with
     * @return a JSON representation of this document
     * @throws org.bson.codecs.configuration.CodecConfigurationException if the registry does not contain a codec for the document values.
     */
    @SuppressWarnings("deprecation")
    public String toJson(final Encoder<BasicDBObject> encoder) {
        return toJson(new JsonWriterSettings(), encoder);
    }

    /**
     * Gets a JSON representation of this document
     *
     * @param writerSettings the json writer settings to use when encoding
     * @param encoder the BasicDBObject codec instance to encode the document with
     * @return a JSON representation of this document
     * @throws org.bson.codecs.configuration.CodecConfigurationException if the registry does not contain a codec for the document values.
     */
    public String toJson(final JsonWriterSettings writerSettings, final Encoder<BasicDBObject> encoder) {
        JsonWriter writer = new JsonWriter(new StringWriter(), writerSettings);
        encoder.encode(writer, this, EncoderContext.builder().isEncodingCollectibleDocument(true).build());
        return writer.getWriter().toString();
    }

    @Override
    public boolean equals(final Object o) {
        if (o == this) {
            return true;
        }

        if (!(o instanceof BSONObject)) {
            return false;
        }

        BSONObject other = (BSONObject) o;

        if (!keySet().equals(other.keySet())) {
            return false;
        }

        return Arrays.equals(toBson(canonicalizeBSONObject(this)), toBson(canonicalizeBSONObject(other)));
    }

    @Override
    public int hashCode() {
        return Arrays.hashCode(toBson(canonicalizeBSONObject(this)));
    }

    private static byte[] toBson(final DBObject dbObject) {
        OutputBuffer outputBuffer = new BasicOutputBuffer();
        DBObjectCodec.getDefaultRegistry().get(DBObject.class).encode(new BsonBinaryWriter(outputBuffer), dbObject,
                EncoderContext.builder().build());
        return outputBuffer.toByteArray();
    }

    /**
     *
Returns a JSON serialization of this object

     *
     *
The output will look like: {@code  {"a":1, "b":["x","y","z"]} }

     *
     * @return JSON serialization
     */
    @SuppressWarnings("deprecation")
    public String toString() {
        return toJson();
    }

    /**
     * If this object was retrieved with only some fields (using a field filter) this method will be called to mark it as such.
     */
    @Override
    public void markAsPartialObject() {
        isPartialObject = true;
    }

    /**
     * Creates a new instance which is a copy of this BasicDBObject.
     *
     * @return a BasicDBObject with exactly the same values as this instance.
     */
    public Object copy() {
        // copy field values into new object
        BasicDBObject newCopy = new BasicDBObject(this.toMap());
        // need to clone the sub obj
        for (final String field : keySet()) {
            Object val = get(field);
            if (val instanceof BasicDBObject) {
                newCopy.put(field, ((BasicDBObject) val).copy());
            } else if (val instanceof BasicDBList) {
                newCopy.put(field, ((BasicDBList) val).copy());
            }
        }
        return newCopy;
    }

    @Override
    public <TDocument> BsonDocument toBsonDocument(final Class<TDocument> documentClass, final CodecRegistry codecRegistry) {
        return new BsonDocumentWrapper<BasicDBObject>(this, codecRegistry.get(BasicDBObject.class));
    }

    // create a copy of "from", but with keys ordered alphabetically
    @SuppressWarnings("unchecked")
    private static Object canonicalize(final Object from) {
        if (from instanceof BSONObject && !(from instanceof BasicBSONList)) {
            return canonicalizeBSONObject((BSONObject) from);
        } else if (from instanceof List) {
            return canonicalizeList((List<Object>) from);
        } else if (from instanceof Map) {
            return canonicalizeMap((Map<String, Object>) from);
        } else {
            return from;
        }
    }

    private static Map<String, Object> canonicalizeMap(final Map<String, Object> from) {
        Map<String, Object> canonicalized = new LinkedHashMap<String, Object>(from.size());
        TreeSet<String> keysInOrder = new TreeSet<String>(from.keySet());
        for (String key : keysInOrder) {
            Object val = from.get(key);
            canonicalized.put(key, canonicalize(val));
        }
        return canonicalized;
    }

    private static DBObject canonicalizeBSONObject(final BSONObject from) {
        BasicDBObject canonicalized = new BasicDBObject();
        TreeSet<String> keysInOrder = new TreeSet<String>(from.keySet());
        for (String key : keysInOrder) {
            Object val = from.get(key);
            canonicalized.put(key, canonicalize(val));
        }
        return canonicalized;
    }

    private static List canonicalizeList(final List<Object> list) {
        List<Object> canonicalized = new ArrayList<Object>(list.size());
        for (Object cur : list) {
            canonicalized.add(canonicalize(cur));
        }
        return canonicalized;
    }
}