/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.ignite.internal.binary.builder;

import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import org.apache.ignite.binary.BinaryInvalidTypeException;
import org.apache.ignite.binary.BinaryObject;
import org.apache.ignite.binary.BinaryObjectBuilder;
import org.apache.ignite.binary.BinaryObjectException;
import org.apache.ignite.binary.BinaryType;
import org.apache.ignite.internal.binary.BinaryArray;
import org.apache.ignite.internal.binary.BinaryContext;
import org.apache.ignite.internal.binary.BinaryEnumArray;
import org.apache.ignite.internal.binary.BinaryEnumObjectImpl;
import org.apache.ignite.internal.binary.BinaryFieldMetadata;
import org.apache.ignite.internal.binary.BinaryMetadata;
import org.apache.ignite.internal.binary.BinaryObjectImpl;
import org.apache.ignite.internal.binary.BinaryObjectOffheapImpl;
import org.apache.ignite.internal.binary.BinarySchema;
import org.apache.ignite.internal.binary.BinarySchemaRegistry;
import org.apache.ignite.internal.binary.BinaryUtils;
import org.apache.ignite.internal.binary.BinaryWriterExImpl;
import org.apache.ignite.internal.binary.GridBinaryMarshaller;
import org.apache.ignite.internal.util.typedef.F;
import org.apache.ignite.internal.util.typedef.internal.U;
import org.apache.ignite.lang.IgniteBiTuple;
import org.apache.ignite.thread.IgniteThread;
import org.jetbrains.annotations.Nullable;

import static org.apache.ignite.internal.MarshallerPlatformIds.JAVA_ID;

/**
 *
 */
public class BinaryObjectBuilderImpl implements BinaryObjectBuilder {
    /** */
    private static final Object REMOVED_FIELD_MARKER = new Object();

    /** */
    private final BinaryContext ctx;

    /** */
    private final int typeId;

    /** May be null. */
    private String typeName;

    /** May be null. */
    private String clsNameToWrite;

    /** */
    private boolean registeredType = true;

    /** */
    private Map<String, Object> assignedVals;

    /** */
    private Map<Integer, Object> readCache;

    /** Position of object in source array, or -1 if object is not created from BinaryObject. */
    private final int start;

    /** Flags. */
    private final short flags;

    /** Total header length */
    private final int hdrLen;

    /** Context of BinaryObject reading process. Or {@code null} if object is not created from BinaryObject. */
    private final BinaryBuilderReader reader;

    /** Affinity key field name. */
    private String affFieldName;

    /**
     * @param clsName Class name.
     * @param ctx Binary context.
     */
    public BinaryObjectBuilderImpl(BinaryContext ctx, String clsName) {
        this(ctx, ctx.typeId(clsName), ctx.userTypeName(clsName));
    }

    /**
     * @param typeName Type name.
     * @param ctx Context.
     * @param typeId Type id.
     */
    public BinaryObjectBuilderImpl(BinaryContext ctx, int typeId, String typeName) {
        this.typeId = typeId;
        this.typeName = typeName;
        this.ctx = ctx;

        start = -1;
        flags = -1;
        reader = null;
        hdrLen = GridBinaryMarshaller.DFLT_HDR_LEN;

        readCache = Collections.emptyMap();
    }

    /**
     * @param obj Object to wrap.
     */
    public BinaryObjectBuilderImpl(BinaryObjectImpl obj) {
        this(new BinaryBuilderReader(obj), obj.start());
        reader.registerObject(this);
    }

    /**
     * @param reader ctx
     * @param start Start.
     */
    BinaryObjectBuilderImpl(BinaryBuilderReader reader, int start) {
        assert reader != null;

        this.reader = reader;
        this.start = start;
        this.flags = reader.readShortPositioned(start + GridBinaryMarshaller.FLAGS_POS);

        byte ver = reader.readBytePositioned(start + GridBinaryMarshaller.PROTO_VER_POS);

        BinaryUtils.checkProtocolVersion(ver);

        int typeId = reader.readIntPositioned(start + GridBinaryMarshaller.TYPE_ID_POS);
        ctx = reader.binaryContext();

        if (typeId == GridBinaryMarshaller.UNREGISTERED_TYPE_ID) {
            int mark = reader.position();

            reader.position(start + GridBinaryMarshaller.DFLT_HDR_LEN);

            clsNameToWrite = reader.readString();

            Class cls;

            try {
                cls = U.forName(clsNameToWrite, ctx.configuration().getClassLoader());
            }
            catch (ClassNotFoundException e) {
                throw new BinaryInvalidTypeException("Failed to load the class: " + clsNameToWrite, e);
            }

            this.typeId = ctx.registerClass(cls, true, false).typeId();

            registeredType = false;

            hdrLen = reader.position() - mark;

            reader.position(mark);
        }
        else {
            this.typeId = typeId;
            hdrLen = GridBinaryMarshaller.DFLT_HDR_LEN;
        }
    }

    /** {@inheritDoc} */
    @Override public BinaryObject build() {
        try (BinaryWriterExImpl writer = new BinaryWriterExImpl(ctx)) {
            Thread curThread = Thread.currentThread();

            if (curThread instanceof IgniteThread)
                writer.failIfUnregistered(((IgniteThread)curThread).isForbiddenToRequestBinaryMetadata());

            writer.typeId(typeId);

            BinaryBuilderSerializer serializationCtx = new BinaryBuilderSerializer();

            serializationCtx.registerObjectWriting(this, 0);

            serializeTo(writer, serializationCtx);

            byte[] arr = writer.array();

            return new BinaryObjectImpl(ctx, arr, 0);
        }
    }

    /**
     * @param writer Writer.
     * @param serializer Serializer.
     */
    void serializeTo(BinaryWriterExImpl writer, BinaryBuilderSerializer serializer) {
        try {
            writer.preWrite(registeredType ? null : clsNameToWrite);

            Set<Integer> remainsFlds = null;

            BinaryType meta = ctx.metadata(typeId);

            Map<String, BinaryFieldMetadata> fieldsMeta = null;

            if (reader != null && BinaryUtils.hasSchema(flags)) {
                BinarySchema schema = reader.schema();

                Map<Integer, Object> assignedFldsById;

                if (assignedVals != null) {
                    assignedFldsById = U.newHashMap(assignedVals.size());

                    for (Map.Entry<String, Object> entry : assignedVals.entrySet()) {
                        String name = entry.getKey();
                        Object val = entry.getValue();

                        int fieldId = ctx.fieldId(typeId, name);

                        assignedFldsById.put(fieldId, val);

                        if (val != REMOVED_FIELD_MARKER)
                            fieldsMeta = checkMetadata(meta, fieldsMeta, val, name, fieldId);
                    }

                    remainsFlds = assignedFldsById.keySet();
                }
                else
                    assignedFldsById = Collections.emptyMap();

                // Get footer details.
                int fieldIdLen = BinaryUtils.fieldIdLength(flags);
                int fieldOffsetLen = BinaryUtils.fieldOffsetLength(flags);

                IgniteBiTuple<Integer, Integer> footer = BinaryUtils.footerAbsolute(reader, start);

                int footerPos = footer.get1();
                int footerEnd = footer.get2();

                // Get raw position.
                int rawPos = BinaryUtils.rawOffsetAbsolute(reader, start);

                // Position reader on data.
                reader.position(start + hdrLen);

                int idx = 0;

                while (reader.position() < rawPos) {
                    int fieldId = schema.fieldId(idx++);
                    int fieldLen =
                        fieldPositionAndLength(footerPos, footerEnd, rawPos, fieldIdLen, fieldOffsetLen).get2();

                    int postPos = reader.position() + fieldLen; // Position where reader will be placed afterwards.

                    footerPos += fieldIdLen + fieldOffsetLen;

                    if (assignedFldsById.containsKey(fieldId)) {
                        Object assignedVal = assignedFldsById.remove(fieldId);

                        if (assignedVal != REMOVED_FIELD_MARKER) {
                            writer.writeFieldId(fieldId);

                            serializer.writeValue(writer, assignedVal);
                        }
                    }
                    else {
                        int type = fieldLen != 0 ? reader.readByte(0) : 0;

                        if (fieldLen != 0 && !BinaryUtils.isPlainArrayType(type) && BinaryUtils.isPlainType(type)) {
                            writer.writeFieldId(fieldId);

                            writer.write(reader.array(), reader.position(), fieldLen);
                        }
                        else {
                            writer.writeFieldId(fieldId);

                            Object val;

                            if (fieldLen == 0)
                                val = null;
                            else if (readCache == null) {
                                val = reader.parseValue();

                                assert reader.position() == postPos;
                            }
                            else
                                val = readCache.get(fieldId);

                            serializer.writeValue(writer, val);
                        }
                    }

                    reader.position(postPos);
                }
            }

            if (assignedVals != null && (remainsFlds == null || !remainsFlds.isEmpty())) {
                for (Map.Entry<String, Object> entry : assignedVals.entrySet()) {
                    Object val = entry.getValue();

                    if (val == REMOVED_FIELD_MARKER)
                        continue;

                    String name = entry.getKey();

                    int fieldId = ctx.fieldId(typeId, name);

                    if (remainsFlds != null && !remainsFlds.contains(fieldId))
                        continue;

                    writer.writeFieldId(fieldId);

                    serializer.writeValue(writer, val);

                    if (reader == null)
                        // Metadata has already been checked.
                        fieldsMeta = checkMetadata(meta, fieldsMeta, val, name, fieldId);
                }
            }

            if (reader != null) {
                // Write raw data if any.
                int rawOff = BinaryUtils.rawOffsetAbsolute(reader, start);
                int footerStart = BinaryUtils.footerStartAbsolute(reader, start);

                if (rawOff < footerStart) {
                    writer.rawWriter();

                    writer.write(reader.array(), rawOff, footerStart - rawOff);
                }

                // Shift reader to the end of the object.
                reader.position(start + BinaryUtils.length(reader, start));
            }

            writer.postWrite(true, registeredType);

            // Update metadata if needed.
            int schemaId = writer.schemaId();

            BinarySchemaRegistry schemaReg = ctx.schemaRegistry(typeId);

            if (schemaReg.schema(schemaId) == null) {
                String typeName = this.typeName;

                if (typeName == null) {
                    assert meta != null;

                    typeName = meta.typeName();
                }

                BinarySchema curSchema = writer.currentSchema();

                String affFieldName0 = affFieldName;

                if (affFieldName0 == null)
                    affFieldName0 = ctx.affinityKeyFieldName(typeId);

                ctx.registerUserClassName(typeId, typeName, writer.failIfUnregistered(), false, JAVA_ID);

                ctx.updateMetadata(typeId, new BinaryMetadata(typeId, typeName, fieldsMeta, affFieldName0,
                    Collections.singleton(curSchema), false, null), writer.failIfUnregistered());

                schemaReg.addSchema(curSchema.schemaId(), curSchema);
            }

            // Update hash code after schema is written.
            writer.postWriteHashCode(registeredType ? null : clsNameToWrite);
        }
        finally {
            writer.popSchema();
        }
    }

    /**
     * Checks metadata when a BinaryObject is being serialized.
     *
     * @param meta Current metadata.
     * @param fieldsMeta Map holding metadata information that has to be updated.
     * @param newVal Field value being serialized.
     * @param name Field name.
     * @param fieldId Field ID.
     */
    private Map<String, BinaryFieldMetadata> checkMetadata(BinaryType meta, Map<String, BinaryFieldMetadata> fieldsMeta,
        Object newVal, String name, int fieldId) {
        String oldFldTypeName = meta == null ? null : meta.fieldTypeName(name);

        boolean nullFieldVal = false;

        int newFldTypeId;

        if (newVal instanceof BinaryValueWithType) {
            newFldTypeId = ((BinaryValueWithType)newVal).typeId();

            if (((BinaryValueWithType)newVal).value() == null)
                nullFieldVal = true;
        }
        // Detect Enum and Enum array type.
        else if (newVal instanceof BinaryEnumObjectImpl)
            newFldTypeId = GridBinaryMarshaller.ENUM;

        else if (newVal.getClass().isArray() && BinaryEnumObjectImpl.class.isAssignableFrom(newVal.getClass().getComponentType()))
            newFldTypeId = GridBinaryMarshaller.ENUM_ARR;

        else if (newVal.getClass().isArray() && BinaryObject.class.isAssignableFrom(newVal.getClass().getComponentType()))
            newFldTypeId = GridBinaryMarshaller.OBJ_ARR;

        else if (newVal instanceof BinaryEnumArray)
            newFldTypeId = GridBinaryMarshaller.ENUM_ARR;

        else if (newVal instanceof BinaryArray)
            newFldTypeId = GridBinaryMarshaller.OBJ_ARR;

        else
            newFldTypeId = BinaryUtils.typeByClass(newVal.getClass());

        if (oldFldTypeName == null) {
            // It's a new field, we have to add it to metadata.
            if (fieldsMeta == null) {
                if (BinaryUtils.FIELDS_SORTED_ORDER)
                    fieldsMeta = new TreeMap<>();
                else
                    fieldsMeta = new LinkedHashMap<>();
            }

            fieldsMeta.put(name, new BinaryFieldMetadata(newFldTypeId, fieldId));
        }
        else if (!nullFieldVal) {
            String newFldTypeName = BinaryUtils.fieldTypeName(newFldTypeId);

            if (!F.eq(newFldTypeName, oldFldTypeName) &&
                !oldFldTypeName.equals(BinaryUtils.fieldTypeName(GridBinaryMarshaller.OBJ))) {
                throw new BinaryObjectException(
                    "Wrong value has been set [" +
                        "typeName=" + (typeName == null ? meta.typeName() : typeName) +
                        ", fieldName=" + name +
                        ", fieldType=" + oldFldTypeName +
                        ", assignedValueType=" + newFldTypeName + ']'
                );
            }
        }

        return fieldsMeta;
    }

    /**
     * Get field position and length.
     *
     * @param footerPos Field position inside the footer (absolute).
     * @param footerEnd Footer end (absolute).
     * @param rawPos Raw data position (absolute).
     * @param fieldIdLen Field ID length.
     * @param fieldOffsetLen Field offset length.
     * @return Tuple with field position and length.
     */
    private IgniteBiTuple<Integer, Integer> fieldPositionAndLength(int footerPos, int footerEnd, int rawPos,
        int fieldIdLen, int fieldOffsetLen) {
        // Get field offset first.
        int fieldOffset = BinaryUtils.fieldOffsetRelative(reader, footerPos + fieldIdLen, fieldOffsetLen);
        int fieldPos = start + fieldOffset;

        // Get field length.
        int fieldLen;

        if (footerPos + fieldIdLen + fieldOffsetLen == footerEnd)
            // This is the last field, compare to raw offset.
            fieldLen = rawPos - fieldPos;
        else {
            // Field is somewhere in the middle, get difference with the next offset.
            int nextFieldOffset = BinaryUtils.fieldOffsetRelative(reader,
                footerPos + fieldIdLen + fieldOffsetLen + fieldIdLen, fieldOffsetLen);

            fieldLen = nextFieldOffset - fieldOffset;
        }

        return F.t(fieldPos, fieldLen);
    }

    /**
     * Initialize read cache if needed.
     */
    private void ensureReadCacheInit() {
        assert reader != null;

        if (readCache == null) {
            int fieldIdLen = BinaryUtils.fieldIdLength(flags);
            int fieldOffsetLen = BinaryUtils.fieldOffsetLength(flags);

            BinarySchema schema = reader.schema();

            Map<Integer, Object> readCache = new HashMap<>();

            IgniteBiTuple<Integer, Integer> footer = BinaryUtils.footerAbsolute(reader, start);

            int footerPos = footer.get1();
            int footerEnd = footer.get2();

            int rawPos = BinaryUtils.rawOffsetAbsolute(reader, start);

            int idx = 0;

            while (footerPos + fieldIdLen < footerEnd) {
                int fieldId = schema.fieldId(idx++);

                IgniteBiTuple<Integer, Integer> posAndLen =
                    fieldPositionAndLength(footerPos, footerEnd, rawPos, fieldIdLen, fieldOffsetLen);

                Object val = reader.getValueQuickly(posAndLen.get1(), posAndLen.get2());

                readCache.put(fieldId, val);

                // Shift current footer position.
                footerPos += fieldIdLen + fieldOffsetLen;
            }

            this.readCache = readCache;
        }
    }

    /**
     * If value of {@link #assignedVals} is null, set it according to {@link BinaryUtils#FIELDS_SORTED_ORDER}.
     */
    private Map<String, Object> assignedValues() {
        if (assignedVals == null) {
            if (BinaryUtils.FIELDS_SORTED_ORDER)
                assignedVals = new TreeMap<>();
            else
                assignedVals = new LinkedHashMap<>();
        }

        return assignedVals;
    }

    /** {@inheritDoc} */
    @Override public <T> T getField(String name) {
        Object val;

        if (assignedVals != null && assignedVals.containsKey(name)) {
            val = assignedVals.get(name);

            if (val == REMOVED_FIELD_MARKER)
                return null;
        }
        else if (reader != null) {
            ensureReadCacheInit();

            int fldId = ctx.fieldId(typeId, name);

            val = readCache.get(fldId);
        }
        else
            return null;

        return (T)BinaryUtils.unwrapLazy(val);
    }

    /** {@inheritDoc} */
    @Override public BinaryObjectBuilder setField(String name, Object val0) {
        Object val = assignedValues().get(name);

        if (val instanceof BinaryValueWithType)
            ((BinaryValueWithType)val).value(val0);
        else {
            Class valCls = (val == null) ? Object.class : val.getClass();

            val = val0 == null ? new BinaryValueWithType(BinaryUtils.typeByClass(valCls), null) : val0;
        }

        assignedValues().put(name, val);

        return this;
    }

    /** {@inheritDoc} */
    @Override public <T> BinaryObjectBuilder setField(String name, @Nullable T val, Class<? super T> type) {
        byte typeId;

        if (Collection.class.equals(type))
            typeId = GridBinaryMarshaller.COL;
        else if (Map.class.equals(type))
            typeId = GridBinaryMarshaller.MAP;
        else
            typeId = BinaryUtils.typeByClass(type);

        assignedValues().put(name, new BinaryValueWithType(typeId, val));

        return this;
    }

    /** {@inheritDoc} */
    @Override public BinaryObjectBuilder setField(String name, @Nullable BinaryObjectBuilder builder) {
        return setField(name, (Object)builder);
    }

    /**
     * Removes field from binary object.
     *
     * @param name Field name.
     * @return {@code this} instance for chaining.
     */
    @Override public BinaryObjectBuilderImpl removeField(String name) {
        assignedValues().put(name, REMOVED_FIELD_MARKER);

        return this;
    }

    /**
     * Creates builder initialized by specified binary object.
     *
     * @param obj Binary object to initialize builder.
     * @return New builder.
     */
    public static BinaryObjectBuilderImpl wrap(BinaryObject obj) {
        BinaryObjectImpl heapObj;

        if (obj instanceof BinaryObjectOffheapImpl)
            heapObj = (BinaryObjectImpl)((BinaryObjectOffheapImpl)obj).heapCopy();
        else
            heapObj = (BinaryObjectImpl)obj;

        return new BinaryObjectBuilderImpl(heapObj);
    }

    /**
     * @return Object start position in source array.
     */
    int start() {
        return start;
    }

    /**
     * @return Object type id.
     */
    public int typeId() {
        return typeId;
    }

    /**
     * Set known affinity key field name.
     *
     * @param affFieldName Affinity key field name.
     */
    public void affinityFieldName(String affFieldName) {
        this.affFieldName = affFieldName;
    }
}
