package io.leangen.graphql.metadata.strategy.value.gson;

import com.google.gson.FieldNamingStrategy;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonSyntaxException;
import io.leangen.geantyref.GenericTypeReflector;
import io.leangen.graphql.annotations.GraphQLInputField;
import io.leangen.graphql.execution.GlobalEnvironment;
import io.leangen.graphql.metadata.InputField;
import io.leangen.graphql.metadata.TypedElement;
import io.leangen.graphql.metadata.exceptions.TypeMappingException;
import io.leangen.graphql.metadata.messages.MessageBundle;
import io.leangen.graphql.metadata.strategy.InputFieldInclusionParams;
import io.leangen.graphql.metadata.strategy.type.TypeTransformer;
import io.leangen.graphql.metadata.strategy.value.InputFieldBuilder;
import io.leangen.graphql.metadata.strategy.value.InputFieldBuilderParams;
import io.leangen.graphql.metadata.strategy.value.InputFieldInfoGenerator;
import io.leangen.graphql.metadata.strategy.value.InputParsingException;
import io.leangen.graphql.metadata.strategy.value.ValueMapper;
import io.leangen.graphql.util.ClassUtils;
import io.leangen.graphql.util.Utils;

import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.AnnotatedType;
import java.lang.reflect.Field;
import java.lang.reflect.Member;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

public class GsonValueMapper implements ValueMapper, InputFieldBuilder {

    private final Gson gson;
    private final InputFieldInfoGenerator inputInfoGen = new InputFieldInfoGenerator();
    private static final Gson NO_CONVERTERS = new Gson();

    GsonValueMapper(Gson gson) {
        this.gson = gson;
    }

    @Override
    @SuppressWarnings("unchecked")
    public <T> T fromInput(Object graphQLInput, Type sourceType, AnnotatedType outputType) throws InputParsingException {
        if (graphQLInput.getClass() == outputType.getType()) {
            return (T) graphQLInput;
        }
        try {
            JsonElement jsonElement = NO_CONVERTERS.toJsonTree(graphQLInput, sourceType);
            return gson.fromJson(jsonElement, outputType.getType());
        } catch (JsonSyntaxException e) {
            throw new InputParsingException(graphQLInput, outputType.getType(), e);
        }
    }

    @Override
    @SuppressWarnings("unchecked")
    public <T> T fromString(String json, AnnotatedType type) {
        if (json == null || String.class.equals(type.getType())) {
            return (T) json;
        }
        try {
            return gson.fromJson(json, type.getType());
        } catch (JsonSyntaxException e) {
            throw new InputParsingException(json, type.getType(), e);
        }
    }

    @Override
    public String toString(Object output, AnnotatedType type) {
        if (output == null || output instanceof String) {
            return (String) output;
        }
        return gson.toJson(output, type.getType());
    }

    /**
     * Unlike Jackson, Gson doesn't expose any of its metadata, so this method is more or less a
     * reimplementation of {@link com.google.gson.internal.bind.ReflectiveTypeAdapterFactory#getBoundFields(Gson, com.google.gson.reflect.TypeToken, Class)}
     *
     * @param params The parameters available to the discovery strategy
     *
     * @return All deserializable fields that could be discovered from this {@link AnnotatedType}
     */
    @Override
    @SuppressWarnings("JavadocReference")
    public Set<InputField> getInputFields(InputFieldBuilderParams params) {
        Map<String, InputField> explicit = fromFields(params);
        Map<String, InputField> implicit = fromGetters(params);
        Map<String, InputField> merged = new HashMap<>(explicit);
        implicit.forEach(merged::putIfAbsent);
        return new HashSet<>(merged.values());
    }

    private Map<String, InputField> fromFields(InputFieldBuilderParams params) {
        AnnotatedType type = params.getType();
        Class<?> raw = ClassUtils.getRawType(type.getType());
        if (raw.isInterface() || raw.isPrimitive()) {
            return Collections.emptyMap();
        }

        Map<String, InputField> inputFields = new HashMap<>();
        while (raw != Object.class) {
            Field[] fields = raw.getDeclaredFields();
            for (Field field : fields) {
                if (gson.excluder().excludeClass(field.getType(), false)
                        || gson.excluder().excludeField(field, false)) {
                    continue;
                }
                List<AnnotatedElement> propertyMembers = ClassUtils.getPropertyMembers(field);
                String fieldName = gson.fieldNamingStrategy().translateName(field);
                InputFieldInclusionParams inclusionParams = InputFieldInclusionParams.builder()
                        .withType(params.getType())
                        .withElementDeclaringClass(field.getDeclaringClass())
                        .withElements(propertyMembers)
                        .withDeserializationInfo(true, isDeserializableInSubType(fieldName, gson.fieldNamingStrategy(), params.getConcreteSubTypes()))
                        .build();
                if (!params.getEnvironment().inclusionStrategy.includeInputField(inclusionParams)) {
                    continue;
                }
                TypedElement element = reduce(type, field, params.getEnvironment().typeTransformer);
                field.setAccessible(true);
                InputField inputField = new InputField(fieldName, getDescription(propertyMembers, params.getEnvironment().messageBundle),
                        element, null, defaultValue(propertyMembers, element.getJavaType(), params.getEnvironment()));
                if (inputFields.containsKey(fieldName)) {
                    throw new IllegalArgumentException(raw + " declares multiple input fields named " + fieldName);
                }
                inputFields.put(fieldName, inputField);
            }
            raw = raw.getSuperclass();
            type = GenericTypeReflector.getExactSuperType(type, raw);
        }
        return inputFields;
    }

    private Map<String, InputField> fromGetters(InputFieldBuilderParams params) {
        AnnotatedType type = params.getType();
        Class<?> raw = ClassUtils.getRawType(type.getType());

        GsonFieldNamingStrategy namingStrategy = new GsonFieldNamingStrategy(params.getEnvironment().messageBundle);
        List<Method> getters = Arrays.stream(raw.getMethods())
                .filter(ClassUtils::isGetter)
                .collect(Collectors.toList());
        Map<String, InputField> inputFields = new HashMap<>();
        for (Method getter : getters) {
            if (gson.excluder().excludeClass(getter.getReturnType(), false)) {
                continue;
            }
            List<AnnotatedElement> propertyMembers = ClassUtils.getPropertyMembers(getter);
            if (propertyMembers.stream().anyMatch(element -> element instanceof Field)) {
                continue;
            }
            String fieldName = namingStrategy.getPropertyName(propertyMembers)
                    .orElse(ClassUtils.getFieldNameFromGetter(getter));
            InputFieldInclusionParams inclusionParams = InputFieldInclusionParams.builder()
                    .withType(params.getType())
                    .withElementDeclaringClass(getter.getDeclaringClass())
                    .withElements(propertyMembers)
                    .withDeserializationInfo(false, isDeserializableInSubType(fieldName, gson.fieldNamingStrategy(), params.getConcreteSubTypes()))
                    .build();
            if (!params.getEnvironment().inclusionStrategy.includeInputField(inclusionParams)) {
                continue;
            }
            TypedElement element = reduce(type, getter, params.getEnvironment().typeTransformer);
            InputField inputField = new InputField(fieldName, getDescription(propertyMembers, params.getEnvironment().messageBundle),
                    element, null, defaultValue(propertyMembers, element.getJavaType(), params.getEnvironment()));
            if (inputFields.containsKey(fieldName)) {
                throw new IllegalArgumentException(raw + " declares multiple input fields named " + fieldName);
            }
            inputFields.put(fieldName, inputField);
        }
        return inputFields;
    }

    protected TypedElement reduce(AnnotatedType declaringType, Field field, TypeTransformer transformer) {
        Optional<TypedElement> fld = Optional.of(element(ClassUtils.getFieldType(field, declaringType), field, declaringType, transformer));
        Optional<TypedElement> setter = ClassUtils.findSetter(field.getDeclaringClass(), field.getName(), field.getType())
                .map(mutator -> element(ClassUtils.getParameterTypes(mutator, declaringType)[0], mutator, declaringType, transformer));
        Optional<TypedElement> getter = ClassUtils.findGetter(field.getDeclaringClass(), field.getName())
                .filter(accessor -> accessor.isAnnotationPresent(GraphQLInputField.class))
                .map(accessor -> element(ClassUtils.getReturnType(accessor, declaringType), accessor, declaringType, transformer));
        return new TypedElement(Utils.flatten(getter, setter, fld).collect(Collectors.toList()));
    }

    protected TypedElement reduce(AnnotatedType declaringType, Method getter, TypeTransformer transformer) {
        Optional<TypedElement> setter = ClassUtils.findSetter(getter.getDeclaringClass(), ClassUtils.getFieldNameFromGetter(getter), getter.getReturnType())
                .map(mutator -> element(ClassUtils.getParameterTypes(mutator, declaringType)[0], mutator, declaringType, transformer));
        Optional<TypedElement> gtr = Optional.of(getter)
                .map(accessor -> element(ClassUtils.getReturnType(accessor, declaringType), accessor, declaringType, transformer));
        List<TypedElement> elements = Utils.flatten(gtr, setter).collect(Collectors.toList());
        return new TypedElement(elements);
    }

    protected String getDescription(List<AnnotatedElement> members, MessageBundle messageBundle) {
        return inputInfoGen.getDescription(members, messageBundle).orElse(null);
    }

    protected Object defaultValue(List<AnnotatedElement> members, AnnotatedType fieldType, GlobalEnvironment environment) {
        return inputInfoGen.defaultValue(members, fieldType, environment).orElse(null);
    }

    private <T extends Member & AnnotatedElement> TypedElement element(AnnotatedType type, T annotatedMember, AnnotatedType declaringType, TypeTransformer transformer) {
        try {
            return new TypedElement(transformer.transform(type), annotatedMember);
        } catch (TypeMappingException e) {
            throw TypeMappingException.ambiguousMemberType(annotatedMember, declaringType, e);
        }
    }

    private boolean isDeserializableInSubType(String fieldName, FieldNamingStrategy namingStrategy, List<Class<?>> concreteSubTypes) {
        return concreteSubTypes.stream().anyMatch(impl -> ClassUtils.findField(impl, field -> namingStrategy.translateName(field).equals(fieldName)).isPresent());
    }

    @Override
    public boolean supports(AnnotatedType type) {
        return true;
    }
}