/*
 * 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.tinkerpop.gremlin.structure.io.graphson;

import org.apache.tinkerpop.gremlin.process.traversal.TraversalStrategy;
import org.apache.tinkerpop.gremlin.process.traversal.strategy.TraversalStrategyProxy;
import org.apache.tinkerpop.shaded.jackson.annotation.JsonTypeInfo;
import org.apache.tinkerpop.shaded.jackson.core.JsonParser;
import org.apache.tinkerpop.shaded.jackson.core.JsonToken;
import org.apache.tinkerpop.shaded.jackson.databind.BeanProperty;
import org.apache.tinkerpop.shaded.jackson.databind.DeserializationContext;
import org.apache.tinkerpop.shaded.jackson.databind.JavaType;
import org.apache.tinkerpop.shaded.jackson.databind.JsonDeserializer;
import org.apache.tinkerpop.shaded.jackson.databind.jsontype.TypeDeserializer;
import org.apache.tinkerpop.shaded.jackson.databind.jsontype.TypeIdResolver;
import org.apache.tinkerpop.shaded.jackson.databind.jsontype.impl.TypeDeserializerBase;
import org.apache.tinkerpop.shaded.jackson.databind.type.TypeFactory;
import org.apache.tinkerpop.shaded.jackson.databind.util.TokenBuffer;

import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;

/**
 * Contains main logic for the whole JSON to Java deserialization. Handles types embedded with the version 2.0 of GraphSON.
 *
 * @author Kevin Gallardo (https://kgdo.me)
 */
public class GraphSONTypeDeserializer extends TypeDeserializerBase {
    private final TypeIdResolver idRes;
    private final String propertyName;
    private final String valuePropertyName;
    private final JavaType baseType;
    private final TypeInfo typeInfo;

    private static final JavaType mapJavaType = TypeFactory.defaultInstance().constructType(LinkedHashMap.class);
    private static final JavaType arrayJavaType = TypeFactory.defaultInstance().constructType(ArrayList.class);

    GraphSONTypeDeserializer(final JavaType baseType, final TypeIdResolver idRes, final String typePropertyName,
                             final TypeInfo typeInfo, final String valuePropertyName){
        super(baseType, idRes, typePropertyName, false, null);
        this.baseType = baseType;
        this.idRes = idRes;
        this.propertyName = typePropertyName;
        this.typeInfo = typeInfo;
        this.valuePropertyName = valuePropertyName;
    }

    @Override
    public TypeDeserializer forProperty(BeanProperty beanProperty) {
        return this;
    }

    @Override
    public JsonTypeInfo.As getTypeInclusion() {
        return JsonTypeInfo.As.WRAPPER_ARRAY;
    }


    @Override
    public TypeIdResolver getTypeIdResolver() {
        return idRes;
    }

    @Override
    public Class<?> getDefaultImpl() {
        return null;
    }

    @Override
    public Object deserializeTypedFromObject(final JsonParser jsonParser, final DeserializationContext deserializationContext) throws IOException {
        return deserialize(jsonParser, deserializationContext);
    }

    @Override
    public Object deserializeTypedFromArray(final JsonParser jsonParser, final DeserializationContext deserializationContext) throws IOException {
        return deserialize(jsonParser, deserializationContext);
    }

    @Override
    public Object deserializeTypedFromScalar(final JsonParser jsonParser, final DeserializationContext deserializationContext) throws IOException {
        return deserialize(jsonParser, deserializationContext);
    }

    @Override
    public Object deserializeTypedFromAny(final JsonParser jsonParser, final DeserializationContext deserializationContext) throws IOException {
        return deserialize(jsonParser, deserializationContext);
    }

    /**
     * Main logic for the deserialization.
     */
    private Object deserialize(final JsonParser jsonParser, final DeserializationContext deserializationContext) throws IOException {
        final TokenBuffer buf = new TokenBuffer(jsonParser.getCodec(), false);
        final TokenBuffer localCopy = new TokenBuffer(jsonParser.getCodec(), false);

        // Detect type
        try {
            // The Type pattern is START_OBJECT -> TEXT_FIELD(propertyName) && TEXT_FIELD(valueProp).
            if (jsonParser.getCurrentToken() == JsonToken.START_OBJECT) {
                buf.writeStartObject();
                String typeName = null;
                boolean valueDetected = false;
                boolean valueDetectedFirst = false;

                for (int i = 0; i < 2; i++) {
                    String nextFieldName = jsonParser.nextFieldName();
                    if (nextFieldName == null) {
                        // empty map or less than 2 fields, go out.
                        break;
                    }
                    if (!nextFieldName.equals(this.propertyName) && !nextFieldName.equals(this.valuePropertyName)) {
                        // no type, go out.
                        break;
                    }

                    if (nextFieldName.equals(this.propertyName)) {
                        // detected "@type" field.
                        typeName = jsonParser.nextTextValue();
                        // keeping the spare buffer up to date in case it's a false detection (only the "@type" property)
                        buf.writeStringField(this.propertyName, typeName);
                        continue;
                    }
                    if (nextFieldName.equals(this.valuePropertyName)) {
                        // detected "@value" field.
                        jsonParser.nextValue();

                        if (typeName == null) {
                            // keeping the spare buffer up to date in case it's a false detection (only the "@value" property)
                            // the problem is that the fields "@value" and "@type" could be in any order
                            buf.writeFieldName(this.valuePropertyName);
                            valueDetectedFirst = true;
                            localCopy.copyCurrentStructure(jsonParser);
                        }
                        valueDetected = true;
                        continue;
                    }
                }

                if (typeName != null && valueDetected) {
                    // Type has been detected pattern detected.
                    final JavaType typeFromId = idRes.typeFromId(deserializationContext, typeName);

                    // the type needs to match what we are deserializing with except for TraversalStrategy which gets
                    // coerced to a TraversalStrategyProxy
                    if (!baseType.isJavaLangObject() && !baseType.equals(typeFromId) &&
                            !(baseType.getRawClass() == TraversalStrategyProxy.class && TraversalStrategy.class.isAssignableFrom(typeFromId.getRawClass()))) {
                        throw new InstantiationException(
                                String.format("Cannot deserialize the value with the detected type contained in the JSON ('%s') " +
                                        "to the type specified in parameter to the object mapper (%s). " +
                                        "Those types are incompatible.", typeName, baseType.getRawClass().toString())
                        );
                    }

                    final JsonDeserializer jsonDeserializer = deserializationContext.findContextualValueDeserializer(typeFromId, null);

                    JsonParser tokenParser;

                    if (valueDetectedFirst) {
                        tokenParser = localCopy.asParser();
                        tokenParser.nextToken();
                    } else {
                        tokenParser = jsonParser;
                    }

                    final Object value = jsonDeserializer.deserialize(tokenParser, deserializationContext);

                    final JsonToken t = jsonParser.nextToken();
                    if (t == JsonToken.END_OBJECT) {
                        // we're good to go
                        return value;
                    } else {
                        // detected the type pattern entirely but the Map contained other properties
                        // For now we error out because we assume that pattern is *only* reserved to
                        // typed values.
                        throw deserializationContext.mappingException("Detected the type pattern in the JSON payload " +
                                "but the map containing the types and values contains other fields. This is not " +
                                "allowed by the deserializer.");
                    }
                }
            }
        } catch (Exception e) {
            throw deserializationContext.mappingException("Could not deserialize the JSON value as required. Nested exception: " + e.toString());
        }

        // Type pattern wasn't detected, however,
        // while searching for the type pattern, we may have moved the cursor of the original JsonParser in param.
        // To compensate, we have filled consistently a TokenBuffer that should contain the equivalent of
        // what we skipped while searching for the pattern.
        // This has a huge positive impact on performances, since JsonParser does not have a 'rewind()',
        // the only other solution would have been to copy the whole original JsonParser. Which we avoid here and use
        // an efficient structure made of TokenBuffer + JsonParserSequence/Concat.
        // Concatenate buf + localCopy + end of original content(jsonParser).
        final JsonParser[] concatenatedArray = {buf.asParser(), localCopy.asParser(), jsonParser};
        final JsonParser parserToUse = new JsonParserConcat(concatenatedArray);
        parserToUse.nextToken();

        // If a type has been specified in parameter, use it to find a deserializer and deserialize:
        if (!baseType.isJavaLangObject()) {
            final JsonDeserializer jsonDeserializer = deserializationContext.findContextualValueDeserializer(baseType, null);
            return jsonDeserializer.deserialize(parserToUse, deserializationContext);
        }
        // Otherwise, detect the current structure:
        else {
            if (parserToUse.isExpectedStartArrayToken()) {
                return deserializationContext.findContextualValueDeserializer(arrayJavaType, null).deserialize(parserToUse, deserializationContext);
            } else if (parserToUse.isExpectedStartObjectToken()) {
                return deserializationContext.findContextualValueDeserializer(mapJavaType, null).deserialize(parserToUse, deserializationContext);
            } else {
                // There's "java.lang.Object" in param, there's no type detected in the payload, the payload isn't a JSON Map or JSON List
                // then consider it a simple type, even though we shouldn't be here if it was a simple type.
                // TODO : maybe throw an error instead?
                // throw deserializationContext.mappingException("Roger, we have a problem deserializing");
                final JsonDeserializer jsonDeserializer = deserializationContext.findContextualValueDeserializer(baseType, null);
                return jsonDeserializer.deserialize(parserToUse, deserializationContext);
            }
        }
    }

    private boolean canReadTypeId() {
        return this.typeInfo == TypeInfo.PARTIAL_TYPES;
    }
}
