package com.epages.restdocs.raml.jsonschema;

import static com.epages.restdocs.raml.jsonschema.ConstraintResolver.isRequired;
import static com.epages.restdocs.raml.jsonschema.ConstraintResolver.maxLengthString;
import static com.epages.restdocs.raml.jsonschema.ConstraintResolver.minLengthString;
import static com.epages.restdocs.raml.jsonschema.JsonFieldPath.isArraySegment;
import static java.util.Collections.emptyList;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.toList;

import java.io.IOException;
import java.io.StringWriter;
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import java.util.stream.Collectors;

import org.everit.json.schema.ArraySchema;
import org.everit.json.schema.BooleanSchema;
import org.everit.json.schema.NullSchema;
import org.everit.json.schema.NumberSchema;
import org.everit.json.schema.ObjectSchema;
import org.everit.json.schema.Schema;
import org.everit.json.schema.StringSchema;
import org.everit.json.schema.internal.JSONPrinter;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.restdocs.payload.FieldDescriptor;
import org.springframework.restdocs.payload.JsonFieldType;

import com.fasterxml.jackson.databind.ObjectMapper;

public class JsonSchemaFromFieldDescriptorsGenerator {


    public String generateSchema(List<FieldDescriptor> fieldDescriptors) {
        return generateSchema(fieldDescriptors, null);
    }

    public String generateSchema(List<FieldDescriptor> fieldDescriptors, String title) {
        List<JsonFieldPath> jsonFieldPaths = distinct(fieldDescriptors).stream()
                .map(JsonFieldPath::compile)
                .collect(toList());

        Schema schema = traverse(emptyList(), jsonFieldPaths, (ObjectSchema.Builder) ObjectSchema.builder().title(title));

        return toFormattedString(unWrapRootArray(jsonFieldPaths, schema));
    }

    /**
     * Make sure that the paths of the FieldDescriptors are distinct
     * If we find multiple descriptors for the same path that are completely equal we take the first one.
     * @throws MultipleNonEqualFieldDescriptors in case we find multiple descriptors for the same path that are not equal
     */
    private List<FieldDescriptor> distinct(List<FieldDescriptor> fieldDescriptors) {
        return fieldDescriptors.stream()
                .collect(Collectors.groupingBy(FieldDescriptor::getPath))
                .values().stream()
                .map(this::reduceToSingleIfAllEqual)
                .collect(toList())
        ;
    }

    private FieldDescriptor reduceToSingleIfAllEqual(List<FieldDescriptor> fieldDescriptors) {
        if (fieldDescriptors.size() == 1) {
            return fieldDescriptors.get(0);
        }
        FieldDescriptor first = fieldDescriptors.get(0);
        final boolean hasDifferentDiscriptors = fieldDescriptors.subList(1, fieldDescriptors.size()).stream()
                .anyMatch(fieldDescriptor -> !equalsOnFields(first, fieldDescriptor));
        if (hasDifferentDiscriptors) {
            throw new MultipleNonEqualFieldDescriptors(first.getPath());
        } else {
            return first;
        }
    }

    private boolean equalsOnFields(FieldDescriptor f1, FieldDescriptor f2) {
        return f1.getPath().equals(f2.getPath())
                && f1.getType().equals(f2.getType())
                && f1.isOptional() == f2.isOptional()
                && f1.isIgnored() == f2.isIgnored();
    }


    private Schema unWrapRootArray(List<JsonFieldPath> jsonFieldPaths, Schema schema) {
        if (schema instanceof ObjectSchema) {
            ObjectSchema objectSchema = (ObjectSchema) schema;
            final Map<String, List<JsonFieldPath>> groups = groupFieldsByFirstRemainingPathSegment(emptyList(), jsonFieldPaths);
            if (groups.keySet().size() ==  1 && groups.keySet().contains("[]")) {
                return ArraySchema.builder().allItemSchema(objectSchema.getPropertySchemas().get("[]")).title(objectSchema.getTitle()).build();
            }

        }
        return schema;
    }

    private String toFormattedString(Schema schema) {
        ObjectMapper objectMapper = Jackson2ObjectMapperBuilder
                .json()
                .indentOutput(true)
                .build();
        StringWriter writer = new StringWriter();
        schema.describeTo(new JSONPrinter(writer));
        try {
            return objectMapper.writeValueAsString(objectMapper.readTree(writer.toString()));
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    private Schema traverse(List<String> traversedSegments, List<JsonFieldPath> jsonFieldPaths, ObjectSchema.Builder builder) {

        Map<String, List<JsonFieldPath>> groupedFields = groupFieldsByFirstRemainingPathSegment(traversedSegments, jsonFieldPaths);
        groupedFields.forEach((propertyName, fieldList) -> {

            List<String> newTraversedSegments = new ArrayList<>(traversedSegments);
            newTraversedSegments.add(propertyName);
            fieldList.stream()
                    .filter(isDirectMatch(newTraversedSegments))
                    .findFirst()
                    .map(directMatch -> {
                        if (fieldList.size() == 1) {
                            handleEndOfPath(builder, propertyName, directMatch.getFieldDescriptor());
                        } else {
                            List<JsonFieldPath> newFields = new ArrayList<>(fieldList);
                            newFields.remove(directMatch);
                            processRemainingSegments(builder, propertyName, newTraversedSegments, newFields, (String) directMatch.getFieldDescriptor().getDescription());
                        }
                        return true;
                    }).orElseGet(() -> {
                        processRemainingSegments(builder, propertyName, newTraversedSegments, fieldList, null);
                        return true;
                    });
        });
        return builder.build();
    }

    private Predicate<JsonFieldPath> isDirectMatch(List<String> traversedSegments) {
        //we have a direct match when there are no remaining segments or when the only following element is an array
        return jsonFieldPath -> {
            List<String> remainingSegments = jsonFieldPath.remainingSegments(traversedSegments);
            return remainingSegments.isEmpty() || (remainingSegments.size() == 1 && isArraySegment(remainingSegments.get(0)));
        };
    }

    private Map<String, List<JsonFieldPath>> groupFieldsByFirstRemainingPathSegment(List<String> traversedSegments, List<JsonFieldPath> jsonFieldPaths) {
        return jsonFieldPaths.stream().collect(groupingBy(j -> j.remainingSegments(traversedSegments).get(0)));
    }

    private void processRemainingSegments(ObjectSchema.Builder builder, String propertyName, List<String> traversedSegments, List<JsonFieldPath> fields, String description) {
        List<String> remainingSegments = fields.get(0).remainingSegments(traversedSegments);
        if (remainingSegments.size() > 0 && isArraySegment(remainingSegments.get(0))) {
            traversedSegments.add(remainingSegments.get(0));
            builder.addPropertySchema(propertyName, ArraySchema.builder()
                    .allItemSchema(traverse(traversedSegments, fields, ObjectSchema.builder()))
                    .description(description)
                    .build());
        } else {
            builder.addPropertySchema(propertyName, traverse(traversedSegments, fields, (ObjectSchema.Builder) ObjectSchema.builder()
                    .description(description)));
        }
    }

    private void handleEndOfPath(ObjectSchema.Builder builder, String propertyName, FieldDescriptor fieldDescriptor) {

        if (fieldDescriptor.isIgnored()) {
            // We don't need to render anything
        } else {
            if (isRequired(fieldDescriptor)) {
                builder.addRequiredProperty(propertyName);
            }
            if (fieldDescriptor.getType().equals(JsonFieldType.NULL) || fieldDescriptor.getType().equals(JsonFieldType.VARIES)) {
                builder.addPropertySchema(propertyName, NullSchema.builder()
                        .description((String) fieldDescriptor.getDescription())
                        .build());
            } else if (fieldDescriptor.getType().equals(JsonFieldType.OBJECT)) {
                builder.addPropertySchema(propertyName, ObjectSchema.builder()
                        .description((String) fieldDescriptor.getDescription())
                        .build());

            } else if (fieldDescriptor.getType().equals(JsonFieldType.ARRAY)) {
                builder.addPropertySchema(propertyName, ArraySchema.builder()
                        .description((String) fieldDescriptor.getDescription())
                        .build());
            } else if (fieldDescriptor.getType().equals(JsonFieldType.BOOLEAN)) {
                builder.addPropertySchema(propertyName, BooleanSchema.builder()
                        .description((String) fieldDescriptor.getDescription())
                        .build());
            } else if (fieldDescriptor.getType().equals(JsonFieldType.NUMBER)) {
                builder.addPropertySchema(propertyName, NumberSchema.builder()
                        .description((String) fieldDescriptor.getDescription())
                        .build());
            } else if (fieldDescriptor.getType().equals(JsonFieldType.STRING)) {
                builder.addPropertySchema(propertyName, StringSchema.builder()
                        .minLength(minLengthString(fieldDescriptor))
                        .maxLength(maxLengthString(fieldDescriptor))
                        .description((String) fieldDescriptor.getDescription())
                        .build());
            } else {
                throw new IllegalArgumentException("unknown field type " + fieldDescriptor.getType());
            }
        }
    }

    static class MultipleNonEqualFieldDescriptors extends RuntimeException {
        MultipleNonEqualFieldDescriptors(String path) {
            super(String.format("Found multiple FieldDescriptors for '%s' with different values", path));
        }
    }
}