package graphql.analysis.values;

import com.google.common.collect.ImmutableList;
import graphql.PublicApi;
import graphql.schema.DataFetchingEnvironment;
import graphql.schema.DataFetchingEnvironmentImpl;
import graphql.schema.GraphQLAppliedDirective;
import graphql.schema.GraphQLAppliedDirectiveArgument;
import graphql.schema.GraphQLArgument;
import graphql.schema.GraphQLEnumType;
import graphql.schema.GraphQLFieldDefinition;
import graphql.schema.GraphQLInputObjectField;
import graphql.schema.GraphQLInputObjectType;
import graphql.schema.GraphQLInputSchemaElement;
import graphql.schema.GraphQLInputType;
import graphql.schema.GraphQLInputValueDefinition;
import graphql.schema.GraphQLList;
import graphql.schema.GraphQLNonNull;
import graphql.schema.GraphQLScalarType;
import graphql.schema.GraphQLTypeUtil;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import static graphql.Assert.assertShouldNeverHappen;
import static graphql.Assert.assertTrue;
import static graphql.analysis.values.ValueVisitor.ABSENCE_SENTINEL;

/**
 * This class allows you to traverse a set of input values according to the type system and optional
 * change the values present.
 * <p>
 * If you just want to traverse without changing anything, just return the value presented to you and nothing will change.
 * <p>
 * If you want to change a value, perhaps in the presence of a directive say on the containing element, then return
 * a new value back in your visitor.
 * <p>
 * This class is intended to be used say inside a DataFetcher, allowing you to change the {@link DataFetchingEnvironment#getArguments()}
 * say before further processing.
 * <p>
 * The values passed in are assumed to be valid and coerced.  This classes does not check for non nullness say or the right coerced objects given
 * the type system.  This is assumed to have occurred earlier in the graphql validation phase.  This also means if you are not careful you can undo the
 * validation that has gone before you.  For example, it would be possible to change values that are illegal according to the type system, such as
 * null values for non-nullable types say, so you need to be careful.
 */
@PublicApi
public class ValueTraverser {

    private static class InputElements implements ValueVisitor.InputElements {

        private final ImmutableList<GraphQLInputSchemaElement> inputElements;
        private final List<GraphQLInputSchemaElement> unwrappedInputElements;
        private final GraphQLInputValueDefinition lastElement;

        private InputElements(GraphQLInputSchemaElement startElement) {
            this.inputElements = ImmutableList.of(startElement);
            this.unwrappedInputElements = ImmutableList.of(startElement);
            this.lastElement = startElement instanceof GraphQLInputValueDefinition ? (GraphQLInputValueDefinition) startElement : null;
        }

        private InputElements(ImmutableList<GraphQLInputSchemaElement> inputElements) {
            this.inputElements = inputElements;
            this.unwrappedInputElements = inputElements.stream()
                    .filter(it -> !(it instanceof GraphQLNonNull || it instanceof GraphQLList))
                    .collect(ImmutableList.toImmutableList());

            List<GraphQLInputValueDefinition> inputValDefs = unwrappedInputElements.stream()
                    .filter(it -> it instanceof GraphQLInputValueDefinition)
                    .map(GraphQLInputValueDefinition.class::cast).collect(Collectors.toList());
            this.lastElement = inputValDefs.isEmpty() ? null : inputValDefs.get(inputValDefs.size() - 1);
        }


        private InputElements push(GraphQLInputSchemaElement inputElement) {
            ImmutableList<GraphQLInputSchemaElement> newSchemaElements = ImmutableList.<GraphQLInputSchemaElement>builder()
                    .addAll(inputElements).add(inputElement).build();
            return new InputElements(newSchemaElements);
        }

        @Override
        public List<GraphQLInputSchemaElement> getInputElements() {
            return inputElements;
        }

        public List<GraphQLInputSchemaElement> getUnwrappedInputElements() {
            return unwrappedInputElements;
        }

        @Override
        public GraphQLInputValueDefinition getLastInputValueDefinition() {
            return lastElement;
        }
    }

    /**
     * This will visit the arguments of a {@link DataFetchingEnvironment} and if the values are changed by the visitor a new environment will be built
     *
     * @param environment the starting data fetching environment
     * @param visitor     the visitor to use
     *
     * @return the same environment if nothing changes or a new one with the {@link DataFetchingEnvironment#getArguments()} changed
     */
    public static DataFetchingEnvironment visitPreOrder(DataFetchingEnvironment environment, ValueVisitor visitor) {
        GraphQLFieldDefinition fieldDefinition = environment.getFieldDefinition();
        Map<String, Object> originalArgs = environment.getArguments();
        Map<String, Object> newArgs = visitPreOrder(originalArgs, fieldDefinition, visitor);
        if (newArgs != originalArgs) {
            return DataFetchingEnvironmentImpl.newDataFetchingEnvironment(environment).arguments(newArgs).build();
        }
        return environment;
    }

    /**
     * This will visit the arguments of a {@link GraphQLFieldDefinition} and if the visitor changes the values, it will return a new set of arguments
     *
     * @param coercedArgumentValues the starting coerced arguments
     * @param fieldDefinition       the field definition
     * @param visitor               the visitor to use
     *
     * @return the same set of arguments if nothing changes or new ones if the visitor changes anything
     */
    public static Map<String, Object> visitPreOrder(Map<String, Object> coercedArgumentValues, GraphQLFieldDefinition fieldDefinition, ValueVisitor visitor) {
        List<GraphQLArgument> fieldArguments = fieldDefinition.getArguments();
        boolean copied = false;
        for (GraphQLArgument fieldArgument : fieldArguments) {
            String key = fieldArgument.getName();
            Object argValue = coercedArgumentValues.get(key);
            InputElements inputElements = new InputElements(fieldArgument);
            Object newValue = visitor.visitArgumentValue(argValue, fieldArgument, inputElements);
            if (hasChanged(newValue, argValue)) {
                if (!copied) {
                    coercedArgumentValues = new LinkedHashMap<>(coercedArgumentValues);
                    copied = true;
                }
                setNewValue(coercedArgumentValues, key, newValue);
            }
            if (newValue != ABSENCE_SENTINEL) {
                newValue = visitPreOrderImpl(argValue, fieldArgument.getType(), inputElements, visitor);
                if (hasChanged(newValue, argValue)) {
                    if (!copied) {
                        coercedArgumentValues = new LinkedHashMap<>(coercedArgumentValues);
                        copied = true;
                    }
                    setNewValue(coercedArgumentValues, key, newValue);
                }
            }
        }
        return coercedArgumentValues;
    }

    /**
     * This will visit a single argument of a {@link GraphQLArgument} and if the visitor changes the value, it will return a new argument value
     * <p>
     * Note you cannot return the ABSENCE_SENTINEL from this method as its makes no sense to be somehow make the argument disappear.  Use
     * {@link #visitPreOrder(Map, GraphQLFieldDefinition, ValueVisitor)} say to remove arguments in the fields map of arguments.
     *
     * @param coercedArgumentValue the starting coerced argument value
     * @param argument             the argument definition
     * @param visitor              the visitor to use
     *
     * @return the same value if nothing changes or a new value if the visitor changes anything
     */
    public static Object visitPreOrder(Object coercedArgumentValue, GraphQLArgument argument, ValueVisitor visitor) {
        InputElements inputElements = new InputElements(argument);
        Object newValue = visitor.visitArgumentValue(coercedArgumentValue, argument, inputElements);
        if (newValue == ABSENCE_SENTINEL) {
            assertShouldNeverHappen("It makes no sense to return the ABSENCE_SENTINEL during the visitPreOrder GraphQLArgument method");
        }
        newValue = visitPreOrderImpl(newValue, argument.getType(), inputElements, visitor);
        if (newValue == ABSENCE_SENTINEL) {
            assertShouldNeverHappen("It makes no sense to return the ABSENCE_SENTINEL during the visitPreOrder GraphQLArgument method");
        }
        return newValue;
    }

    /**
     * This will visit a single argument of a {@link GraphQLAppliedDirective} and if the visitor changes the value, it will return a new argument value
     * <p>
     * Note you cannot return the ABSENCE_SENTINEL from this method as its makes no sense to be somehow make the argument disappear.
     *
     * @param coercedArgumentValue the starting coerced argument value
     * @param argument             the applied argument
     * @param visitor              the visitor to use
     *
     * @return the same value if nothing changes or a new value if the visitor changes anything
     */
    public static Object visitPreOrder(Object coercedArgumentValue, GraphQLAppliedDirectiveArgument argument, ValueVisitor visitor) {
        InputElements inputElements = new InputElements(argument);
        Object newValue = visitor.visitAppliedDirectiveArgumentValue(coercedArgumentValue, argument, inputElements);
        if (newValue == ABSENCE_SENTINEL) {
            assertShouldNeverHappen("It makes no sense to return the ABSENCE_SENTINEL during the visitPreOrder GraphQLAppliedDirectiveArgument method");
        }
        newValue = visitPreOrderImpl(newValue, argument.getType(), inputElements, visitor);
        if (newValue == ABSENCE_SENTINEL) {
            assertShouldNeverHappen("It makes no sense to return the ABSENCE_SENTINEL during the visitPreOrder GraphQLAppliedDirectiveArgument method");
        }
        return newValue;
    }

    private static Object visitPreOrderImpl(Object coercedValue, GraphQLInputType startingInputType, InputElements containingElements, ValueVisitor visitor) {
        if (startingInputType instanceof GraphQLNonNull) {
            containingElements = containingElements.push(startingInputType);
        }
        GraphQLInputType inputType = GraphQLTypeUtil.unwrapNonNullAs(startingInputType);
        containingElements = containingElements.push(inputType);
        if (inputType instanceof GraphQLList) {
            return visitListValue(coercedValue, (GraphQLList) inputType, containingElements, visitor);
        } else if (inputType instanceof GraphQLInputObjectType) {
            GraphQLInputObjectType inputObjectType = (GraphQLInputObjectType) inputType;
            return visitObjectValue(coercedValue, inputObjectType, containingElements, visitor);
        } else if (inputType instanceof GraphQLScalarType) {
            return visitor.visitScalarValue(coercedValue, (GraphQLScalarType) inputType, containingElements);
        } else if (inputType instanceof GraphQLEnumType) {
            return visitor.visitEnumValue(coercedValue, (GraphQLEnumType) inputType, containingElements);
        } else {
            return assertShouldNeverHappen("ValueTraverser can only be called on full materialised schemas");
        }
    }

    private static Object visitObjectValue(Object coercedValue, GraphQLInputObjectType inputObjectType, InputElements containingElements, ValueVisitor visitor) {
        if (coercedValue != null) {
            assertTrue(coercedValue instanceof Map, () -> "A input object type MUST have an Map<String,Object> value");
        }
        @SuppressWarnings("unchecked")
        Map<String, Object> map = (Map<String, Object>) coercedValue;
        Map<String, Object> newMap = visitor.visitInputObjectValue(map, inputObjectType, containingElements);
        if (newMap == ABSENCE_SENTINEL) {
            return ABSENCE_SENTINEL;
        }
        if (newMap != null) {
            boolean copied = false;
            for (Map.Entry<String, Object> entry : newMap.entrySet()) {
                String key = entry.getKey();
                GraphQLInputObjectField inputField = inputObjectType.getField(key);
                /// should we assert if the map contain a key that's not a field ?
                if (inputField != null) {
                    InputElements inputElementsWithField = containingElements.push(inputField);
                    Object newValue = visitor.visitInputObjectFieldValue(entry.getValue(), inputObjectType, inputField, inputElementsWithField);
                    if (hasChanged(newValue, entry.getValue())) {
                        if (!copied) {
                            newMap = new LinkedHashMap<>(newMap);
                            copied = true;
                        }
                        setNewValue(newMap, key, newValue);
                    }
                    // if the value has gone - then we cant descend into it
                    if (newValue != ABSENCE_SENTINEL) {
                        newValue = visitPreOrderImpl(newValue, inputField.getType(), inputElementsWithField, visitor);
                        if (hasChanged(newValue, entry.getValue())) {
                            if (!copied) {
                                newMap = new LinkedHashMap<>(newMap);
                                copied = true;
                            }
                            setNewValue(newMap, key, newValue);
                        }
                    }
                }
            }
            return newMap;
        } else {
            return null;
        }
    }

    private static Object visitListValue(Object coercedValue, GraphQLList listInputType, InputElements containingElements, ValueVisitor visitor) {
        if (coercedValue != null) {
            assertTrue(coercedValue instanceof List, () -> "A list type MUST have an List value");
        }
        @SuppressWarnings("unchecked")
        List<Object> list = (List<Object>) coercedValue;
        List<Object> newList = visitor.visitListValue(list, listInputType, containingElements);
        if (newList == ABSENCE_SENTINEL) {
            return ABSENCE_SENTINEL;
        }
        if (newList != null) {
            GraphQLInputType inputType = GraphQLTypeUtil.unwrapOneAs(listInputType);
            ImmutableList.Builder<Object> copiedList = null;
            int i = 0;
            for (Object subValue : newList) {
                Object newValue = visitPreOrderImpl(subValue, inputType, containingElements, visitor);
                if (copiedList != null) {
                    if (newValue != ABSENCE_SENTINEL) {
                        copiedList.add(newValue);
                    }
                } else if (hasChanged(newValue, subValue)) {
                    // go into copy mode because something has changed
                    // copy previous values up to this point
                    copiedList = ImmutableList.builder();
                    for (int j = 0; j < i; j++) {
                        copiedList.add(newList.get(j));
                    }
                    if (newValue != ABSENCE_SENTINEL) {
                        copiedList.add(newValue);
                    }
                }
                i++;
            }
            if (copiedList != null) {
                return copiedList.build();
            } else {
                return newList;
            }
        } else {
            return null;
        }
    }

    private static boolean hasChanged(Object newValue, Object oldValue) {
        return newValue != oldValue || newValue == ABSENCE_SENTINEL;
    }

    private static void setNewValue(Map<String, Object> newMap, String key, Object newValue) {
        if (newValue == ABSENCE_SENTINEL) {
            newMap.remove(key);
        } else {
            newMap.put(key, newValue);
        }
    }

}
