/*
 * Copyright 2016-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License").
 * You may not use this file except in compliance with the License.
 * A copy of the License is located at
 *
 *  http://aws.amazon.com/apache2.0
 *
 * or in the "license" file accompanying this file. This file 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 com.amazonaws.codegen;

import com.amazonaws.codegen.model.config.customization.CustomizationConfig;
import com.amazonaws.codegen.model.config.customization.ShapeModifier;
import com.amazonaws.codegen.model.config.customization.ShapeModifier_ModifyModel;
import com.amazonaws.codegen.model.config.customization.ShapeSubstitution;
import com.amazonaws.codegen.model.intermediate.Example;
import com.amazonaws.codegen.model.intermediate.ServiceExamples;
import com.amazonaws.codegen.model.service.Input;
import com.amazonaws.codegen.model.service.Member;
import com.amazonaws.codegen.model.service.Operation;
import com.amazonaws.codegen.model.service.Output;
import com.amazonaws.codegen.model.service.ServiceModel;
import com.amazonaws.codegen.model.service.Shape;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/**
 * Transforms the examples so that they are in line with any shape related
 * customizations for the given service.
 */
public class ExamplesCustomizer {
    private static final ObjectMapper MAPPER = new ObjectMapper();
    private final ServiceModel serviceModel;
    private final CustomizationConfig customizationConfig;

    public ExamplesCustomizer(ServiceModel serviceModel, CustomizationConfig customizationConfig) {
        this.serviceModel = serviceModel;
        this.customizationConfig = customizationConfig;
    }

    /**
     * Apply the configured {@link CustomizationConfig} to the given service
     * examples.
     *
     * @param serviceExamples The service examples.
     *
     * @return The customized service examples.
     */
    public ServiceExamples applyCustomizationsToExamples(ServiceExamples serviceExamples) {
        if (customizationConfig == null) return serviceExamples;

        serviceExamples.getOperationExamples().entrySet()
                .forEach(e -> {
                    String operationName = e.getKey();
                    Operation operation = serviceModel.getOperation(operationName);
                    e.getValue().forEach(example -> applyCustomizationsToExample(example, operation));
                });
        return serviceExamples;
    }

    private Example applyCustomizationsToExample(Example example, Operation operation) {
        if (example == null) return null;

        System.out.println(String.format("Customizing operation example : %s", example.getId()));

        Input input = operation.getInput();
        if (input != null) {
            String inputShapeName = input.getShape();
            Shape inputShape = serviceModel.getShape(inputShapeName);
            JsonNode inputValue = example.getInput();

            example.setInput(applyCustomizationsToShapeJson(inputShapeName, inputShape, inputValue));
        }

        Output output = operation.getOutput();
        if (output != null) {
            String outputShapeName = output.getShape();
            Shape outputShape = serviceModel.getShape(outputShapeName);
            JsonNode outputValue = example.getOutput();

            example.setOutput(applyCustomizationsToShapeJson(outputShapeName, outputShape, outputValue));
        }

        return example;
    }

    /**
     * Recursively apply any declared customizations to this JSON value
     * according to the given shape and the customizations declared for the
     * shape.
     *
     * @param shapeName The name of the shape.
     * @param shape The shape of the JSON value.
     * @param valueNode The JSON value to customize.
     *
     * @return The customized JSON value.
     */
    private JsonNode applyCustomizationsToShapeJson(String shapeName, Shape shape, JsonNode valueNode) {
        // Don't bother going any further if we're not dealing with an array or
        // object JSON node; there's not much we can do to "massage" this value
        if (valueNode == null || !valueNode.isContainerNode()) {
            return valueNode;
        }

        // Apply modifications first
        valueNode = applyModificationsToShapeJson(shapeName, valueNode);

        ShapeSubstitution shapeSub = null;
        if (customizationConfig.getShapeSubstitutions() != null) {
            shapeSub = customizationConfig.getShapeSubstitutions().get(shapeName);
        }

        if (shapeSub != null) {
            String substituteShapeName = shapeSub.getEmitAsShape();
            Shape substituteShape = serviceModel.getShape(substituteShapeName);
            JsonNode substituteValue = valueNode;
            if (shapeSub.getEmitFromMember() != null) {
                substituteValue = valueNode.get(shapeSub.getEmitFromMember());
                if (substituteValue == null) {
                    System.err.println(String.format("Warning: Substituting shape '%s' for its"
                                    + " member '%s' as shape '%s' produced null value. Original"
                                    + " value: %s", shapeName, shapeSub.getEmitFromMember(),
                            substituteShapeName, valueNode.toString()));
                }
            }
            System.out.println(String.format("Substituting shape %s with %s. %s -> %s", shapeName,
                    substituteShapeName, valueNode.toString(), Objects.toString(substituteValue)));

            return applyCustomizationsToShapeJson(substituteShapeName, substituteShape, substituteValue);
        } else {
            switch (shape.getType()) {
                // Apply customizations to each member
                case "map":
                case "structure": {
                    if (shape.getMembers() == null) {
                        return valueNode;
                    }

                    ObjectNode obj = MAPPER.createObjectNode();

                    for (Map.Entry<String, Member> e : shape.getMembers().entrySet()) {
                        Member member = e.getValue();
                        String memberName = e.getKey();
                        String memberShapeName = member.getShape();
                        Shape memberShape = serviceModel.getShape(memberShapeName);
                        JsonNode memberValue = valueNode.get(memberName);

                        // Only set if it's not null, otherwise the generated
                        // sample code could potentially have lots of
                        // unnecessary 'withProperty(null)' calls.
                        if (memberValue != null) {
                            obj.set(memberName, applyCustomizationsToShapeJson(memberShapeName,
                                                    memberShape, memberValue));
                        }
                    }

                    return obj;
                }

                // Apply customizations to each element
                case "list": {
                    ArrayNode list = MAPPER.createArrayNode();

                    String memberShapeName = shape.getListMember().getShape();
                    Shape memberShape = serviceModel.getShape(memberShapeName);

                    for (JsonNode e : valueNode) {
                        // apply any customizations to the list elements
                        list.add(applyCustomizationsToShapeJson(memberShapeName, memberShape, e));
                    }

                    return list;
                }
                default:
                    throw new RuntimeException("Unknown shape type: " + shape.getType());
            }
        }
    }

    private JsonNode applyModificationsToShapeJson(String shapeName, JsonNode valueNode) {
        if (customizationConfig.getShapeModifiers() == null) return valueNode;

        ShapeModifier allShapeMode = customizationConfig.getShapeModifiers().get("*");
        ShapeModifier shapeMod = customizationConfig.getShapeModifiers().get(shapeName);

        valueNode = applyShapeModifier(valueNode, allShapeMode);
        valueNode = applyShapeModifier(valueNode, shapeMod);

        return valueNode;
    }

    /**
     * Apply any shape modifiers to the JSON value. This only takes care of
     * 'exclude' and 'emitPropertyName'.
     *
     * @param node The JSON node.
     * @param modifier The shape modifier.
     * @return The modified node.
     */
    private JsonNode applyShapeModifier(JsonNode node, ShapeModifier modifier) {
        if (node == null || modifier == null) {
            return node;
        }

        if (modifier.getExclude() == null && modifier.getModify() == null) {
            return node;
        }

        if (!node.isObject()) return node;

        final ObjectNode obj = (ObjectNode) node;

        ObjectNode modified = MAPPER.createObjectNode();
        // Filter any excluded members
        final List<String> excludes = modifier.getExclude() != null ? modifier.getExclude() : Collections.emptyList();
        obj.fieldNames().forEachRemaining(m -> {
            if (!excludes.contains(m)) {
                modified.set(m, obj.get(m));
            }
        });
        // Apply property renames
        final List<Map<String, ShapeModifier_ModifyModel>> modify = modifier.getModify() != null ? modifier.getModify() : Collections.emptyList();
        modify.forEach(memberMods ->
            memberMods.entrySet().forEach(memberMod -> {
                String memberName = memberMod.getKey();
                ShapeModifier_ModifyModel modelModify = memberMod.getValue();
                if (modelModify.getEmitPropertyName() != null) {
                    String newName = modelModify.getEmitPropertyName();
                    modified.set(newName, modified.get(memberName));
                    modified.remove(memberName);
                    memberName = newName;
                }
            })
        );

        return modified;
    }
}
