/*
 * Copyright (c) 2008-2014 MongoDB, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.bson;

import com.mongodb.DBRef;
import org.bson.io.BasicOutputBuffer;
import org.bson.io.OutputBuffer;
import org.bson.types.BSONTimestamp;
import org.bson.types.Binary;
import org.bson.types.Code;
import org.bson.types.CodeWScope;
import org.bson.types.MaxKey;
import org.bson.types.MinKey;
import org.bson.types.ObjectId;
import org.bson.types.Symbol;

import java.lang.reflect.Array;
import java.util.Date;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Pattern;

import static org.bson.BSON.regexFlags;

/**
 * This is meant to be pooled or cached. There is some per instance memory for string conversion, etc...
 */
public class BasicBSONEncoder implements BSONEncoder {

    private BsonBinaryWriter bsonWriter;
    private OutputBuffer outputBuffer;

    @Override
    public byte[] encode(final BSONObject document) {
        OutputBuffer outputBuffer = new BasicOutputBuffer();
        set(outputBuffer);
        putObject(document);
        done();
        return outputBuffer.toByteArray();
    }

    @Override
    public void done() {
        this.bsonWriter.close();
        this.bsonWriter = null;
    }

    @Override
    public void set(final OutputBuffer buffer) {
        if (this.bsonWriter != null) {
            throw new IllegalStateException("Performing another operation at this moment");
        }
        outputBuffer = buffer;
        this.bsonWriter = new BsonBinaryWriter(buffer);
    }

    /**
     * Gets the buffer the BSON is being encoded into.
     *
     * @return the OutputBuffer
     */
    protected OutputBuffer getOutputBuffer() {
        return outputBuffer;
    }

    /**
     * Gets the writer responsible for writing the encoded BSON.
     *
     * @return the writer used to write the encoded BSON
     */
    protected BsonBinaryWriter getBsonWriter() {
        return bsonWriter;
    }

    /**
     * Encodes a {@code BSONObject}. This is for the higher level api calls.
     *
     * @param document the document to encode
     * @return the number of characters in the encoding
     */
    @Override
    public int putObject(final BSONObject document) {
        int startPosition = getOutputBuffer().getPosition();
        bsonWriter.writeStartDocument();

        if (isTopLevelDocument() && document.containsField("_id")) {
            _putObjectField("_id", document.get("_id"));
        }

        for (final String key : document.keySet()) {
            if (isTopLevelDocument() && key.equals("_id")) {
                continue;
            }
            _putObjectField(key, document.get(key));
        }
        bsonWriter.writeEndDocument();
        return getOutputBuffer().getPosition() - startPosition;
    }

    private boolean isTopLevelDocument() {
        return bsonWriter.getContext().getParentContext() == null;
    }

    /**
     * Writes a field name
     *
     * @param name the field name
     */
    protected void putName(final String name) {
        if (bsonWriter.getState() == AbstractBsonWriter.State.NAME) {
            bsonWriter.writeName(name);
        }
    }

    /**
     * Encodes any Object type
     *
     * @param name         the field name
     * @param initialValue the value to write
     */
    protected void _putObjectField(final String name, final Object initialValue) {
        if ("_transientFields".equals(name)) {
            return;
        }
        if (name.contains("\0")) {
            throw new IllegalArgumentException("Document field names can't have a NULL character. (Bad Key: '" + name + "')");
        }

        if ("$where".equals(name) && initialValue instanceof String) {
            putCode(name, new Code((String) initialValue));
        }

        Object value = BSON.applyEncodingHooks(initialValue);

        if (value == null) {
            putNull(name);
        } else if (value instanceof Date) {
            putDate(name, (Date) value);
        } else if (value instanceof Number) {
            putNumber(name, (Number) value);
        } else if (value instanceof Character) {
            putString(name, value.toString());
        } else if (value instanceof String) {
            putString(name, value.toString());
        } else if (value instanceof ObjectId) {
            putObjectId(name, (ObjectId) value);
        } else if (value instanceof Boolean) {
            putBoolean(name, (Boolean) value);
        } else if (value instanceof Pattern) {
            putPattern(name, (Pattern) value);
        } else if (value instanceof Iterable) {
            putIterable(name, (Iterable) value);
        } else if (value instanceof BSONObject) {
            putObject(name, (BSONObject) value);
        } else if (value instanceof Map) {
            putMap(name, (Map) value);
        } else if (value instanceof byte[]) {
            putBinary(name, (byte[]) value);
        } else if (value instanceof Binary) {
            putBinary(name, (Binary) value);
        } else if (value instanceof UUID) {
            putUUID(name, (UUID) value);
        } else if (value.getClass().isArray()) {
            putArray(name, value);
        } else if (value instanceof Symbol) {
            putSymbol(name, (Symbol) value);
        } else if (value instanceof BSONTimestamp) {
            putTimestamp(name, (BSONTimestamp) value);
        } else if (value instanceof CodeWScope) {
            putCodeWScope(name, (CodeWScope) value);
        } else if (value instanceof Code) {
            putCode(name, (Code) value);
        } else if (value instanceof DBRef) {
            BSONObject temp = new BasicBSONObject();
            DBRef dbRef = (DBRef) value;
            temp.put("$ref", dbRef.getCollectionName());
            temp.put("$id", dbRef.getId());
            if (dbRef.getDatabaseName() != null) {
                temp.put("$db", dbRef.getDatabaseName());
            }
            putObject(name, temp);
        } else if (value instanceof MinKey) {
            putMinKey(name);
        } else if (value instanceof MaxKey) {
            putMaxKey(name);
        } else if (putSpecial(name, value)) {
            // no-op
        } else {
            throw new IllegalArgumentException("Can't serialize " + value.getClass());
        }

    }

    /**
     * Encodes a null value
     *
     * @param name the field name
     * @see org.bson.BsonType#NULL
     */
    protected void putNull(final String name) {
        putName(name);
        bsonWriter.writeNull();
    }

    /**
     * Encodes an undefined value
     *
     * @param name the field name
     * @see org.bson.BsonType#UNDEFINED
     */
    protected void putUndefined(final String name) {
        putName(name);
        bsonWriter.writeUndefined();
    }

    /**
     * Encodes a BSON timestamp
     *
     * @param name      the field name
     * @param timestamp the timestamp to encode
     * @see org.bson.BsonType#TIMESTAMP
     */
    protected void putTimestamp(final String name, final BSONTimestamp timestamp) {
        putName(name);
        bsonWriter.writeTimestamp(new BsonTimestamp(timestamp.getTime(), timestamp.getInc()));
    }

    /**
     * Encodes a field to a {@link org.bson.BsonType#JAVASCRIPT} value.
     *
     * @param name the field name
     * @param code the value
     */
    protected void putCode(final String name, final Code code) {
        putName(name);
        bsonWriter.writeJavaScript(code.getCode());
    }

    /**
     * Encodes a field to a {@link org.bson.BsonType#JAVASCRIPT_WITH_SCOPE} value.
     *
     * @param name       the field name
     * @param codeWScope the value
     */
    protected void putCodeWScope(final String name, final CodeWScope codeWScope) {
        putName(name);
        bsonWriter.writeJavaScriptWithScope(codeWScope.getCode());
        putObject(codeWScope.getScope());
    }

    /**
     * Encodes a field with a {@code Boolean} or {@code boolean} value
     *
     * @param name  the field name
     * @param value the value
     */
    protected void putBoolean(final String name, final Boolean value) {
        putName(name);
        bsonWriter.writeBoolean(value);
    }

    /**
     * Encodes a field with data and time value.
     *
     * @param name the field name
     * @param date the value
     * @see org.bson.BsonType#DATE_TIME
     */
    protected void putDate(final String name, final Date date) {
        putName(name);
        bsonWriter.writeDateTime(date.getTime());
    }

    /**
     * Encodes any number field.
     *
     * @param name   the field name
     * @param number the value
     */
    protected void putNumber(final String name, final Number number) {
        putName(name);
        if (number instanceof Integer || number instanceof Short || number instanceof Byte || number instanceof AtomicInteger) {
            bsonWriter.writeInt32(number.intValue());
        } else if (number instanceof Long || number instanceof AtomicLong) {
            bsonWriter.writeInt64(number.longValue());
        } else if (number instanceof Float || number instanceof Double) {
            bsonWriter.writeDouble(number.doubleValue());
        } else {
            throw new IllegalArgumentException("Can't serialize " + number.getClass());
        }
    }

    /**
     * Encodes a byte array field
     *
     * @param name  the field name
     * @param bytes the value
     * @see org.bson.BsonType#BINARY
     */
    protected void putBinary(final String name, final byte[] bytes) {
        putName(name);
        bsonWriter.writeBinaryData(new BsonBinary(bytes));
    }

    /**
     * Encodes a Binary field
     *
     * @param name   the field name
     * @param binary the value
     * @see org.bson.BsonType#BINARY
     */
    protected void putBinary(final String name, final Binary binary) {
        putName(name);
        bsonWriter.writeBinaryData(new BsonBinary(binary.getType(), binary.getData()));
    }

    /**
     * Encodes a field with a {@link java.util.UUID} value.  This is encoded to a binary value of subtype {@link
     * org.bson.BsonBinarySubType#UUID_LEGACY}
     *
     * @param name the field name
     * @param uuid the value
     */
    protected void putUUID(final String name, final UUID uuid) {
        putName(name);
        byte[] bytes = new byte[16];
        writeLongToArrayLittleEndian(bytes, 0, uuid.getMostSignificantBits());
        writeLongToArrayLittleEndian(bytes, 8, uuid.getLeastSignificantBits());
        bsonWriter.writeBinaryData(new BsonBinary(BsonBinarySubType.UUID_LEGACY, bytes));
    }

    /**
     * Encodes a Symbol field
     *
     * @param name   the field name
     * @param symbol the value
     * @see org.bson.BsonType#SYMBOL
     */
    protected void putSymbol(final String name, final Symbol symbol) {
        putName(name);
        bsonWriter.writeSymbol(symbol.getSymbol());
    }

    /**
     * Encodes a String field
     *
     * @param name  the field name
     * @param value the value
     * @see org.bson.BsonType#STRING
     */
    protected void putString(final String name, final String value) {
        putName(name);
        bsonWriter.writeString(value);
    }

    /**
     * Encodes a Pattern field to a {@link org.bson.BsonType#REGULAR_EXPRESSION}.
     *
     * @param name  the field name
     * @param value the value
     * @mongodb.driver.manual reference/operator/query/regex/ $regex
     * @see org.bson.BsonType#BINARY
     */
    protected void putPattern(final String name, final Pattern value) {
        putName(name);
        bsonWriter.writeRegularExpression(new BsonRegularExpression(value.pattern(), regexFlags(value.flags())));
    }

    /**
     * Encodes an ObjectId field to a {@link org.bson.BsonType#OBJECT_ID}.
     *
     * @param name     the field name
     * @param objectId the value
     */
    protected void putObjectId(final String name, final ObjectId objectId) {
        putName(name);
        bsonWriter.writeObjectId(objectId);
    }

    /**
     * Encodes an array field.
     *
     * @param name   the field name
     * @param object the array, which can be any sort of primitive or String array
     */
    protected void putArray(final String name, final Object object) {
        putName(name);
        bsonWriter.writeStartArray();
        if (object instanceof int[]) {
            for (final int i : (int[]) object) {
                bsonWriter.writeInt32(i);
            }
        } else if (object instanceof long[]) {
            for (final long i : (long[]) object) {
                bsonWriter.writeInt64(i);
            }
        } else if (object instanceof float[]) {
            for (final float i : (float[]) object) {
                bsonWriter.writeDouble(i);
            }
        } else if (object instanceof short[]) {
            for (final short i : (short[]) object) {
                bsonWriter.writeInt32(i);
            }
        } else if (object instanceof byte[]) {
            for (final byte i : (byte[]) object) {
                bsonWriter.writeInt32(i);
            }
        } else if (object instanceof double[]) {
            for (final double i : (double[]) object) {
                bsonWriter.writeDouble(i);
            }
        } else if (object instanceof boolean[]) {
            for (final boolean i : (boolean[]) object) {
                bsonWriter.writeBoolean(i);
            }
        } else if (object instanceof String[]) {
            for (final String i : (String[]) object) {
                bsonWriter.writeString(i);
            }
        } else {
            int length = Array.getLength(object);
            for (int i = 0; i < length; i++) {
                _putObjectField(String.valueOf(i), Array.get(object, i));
            }
        }
        bsonWriter.writeEndArray();
    }

    /**
     * Encodes an Iterable, for example {@code List} values
     *
     * @param name     the field name
     * @param iterable the value
     */
    @SuppressWarnings("rawtypes")
    protected void putIterable(final String name, final Iterable iterable) {
        putName(name);
        bsonWriter.writeStartArray();
        int i = 0;
        for (final Object o : iterable) {
            _putObjectField(String.valueOf(i), o);
        }
        bsonWriter.writeEndArray();
    }

    /**
     * Encodes a map, as a BSON document
     *
     * @param name the field name
     * @param map  the value
     */
    @SuppressWarnings({"rawtypes", "unchecked"})
    protected void putMap(final String name, final Map map) {
        putName(name);
        bsonWriter.writeStartDocument();
        for (final Map.Entry entry : (Set<Map.Entry>) map.entrySet()) {
            _putObjectField((String) entry.getKey(), entry.getValue());
        }
        bsonWriter.writeEndDocument();
    }

    /**
     * Encodes any {@code BSONObject}, as a document
     *
     * @param name     the field name
     * @param document the value
     * @return the number of characters in the encoding
     */
    protected int putObject(final String name, final BSONObject document) {
        putName(name);
        return putObject(document);
    }

    /**
     * Special values are not encoded into documents.
     *
     * @param name    the field name
     * @param special the value
     * @return true if the operation is successful. This implementation always returns false.
     */
    protected boolean putSpecial(final String name, final Object special) {
        return false;
    }

    /**
     * Encodes a field to a {@link org.bson.BsonType#MIN_KEY} value.
     *
     * @param name the field name
     */
    protected void putMinKey(final String name) {
        putName(name);
        bsonWriter.writeMinKey();
    }

    /**
     * Encodes a field to a {@link org.bson.BsonType#MAX_KEY} value.
     *
     * @param name the field name
     */
    protected void putMaxKey(final String name) {
        putName(name);
        bsonWriter.writeMaxKey();
    }

    private static void writeLongToArrayLittleEndian(final byte[] bytes, final int offset, final long x) {
        bytes[offset] = (byte) (0xFFL & (x));
        bytes[offset + 1] = (byte) (0xFFL & (x >> 8));
        bytes[offset + 2] = (byte) (0xFFL & (x >> 16));
        bytes[offset + 3] = (byte) (0xFFL & (x >> 24));
        bytes[offset + 4] = (byte) (0xFFL & (x >> 32));
        bytes[offset + 5] = (byte) (0xFFL & (x >> 40));
        bytes[offset + 6] = (byte) (0xFFL & (x >> 48));
        bytes[offset + 7] = (byte) (0xFFL & (x >> 56));
    }

}
