package ru.yandex.webmaster3.core.http;

import java.io.BufferedReader;
import java.io.IOException;
import java.lang.reflect.Array;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;

import javax.servlet.ServletException;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.Part;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JavaType;
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.TreeTraversingParser;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.fasterxml.jackson.datatype.joda.JodaModule;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.joda.time.DateTime;
import org.joda.time.Instant;
import org.joda.time.LocalDate;

import ru.yandex.autodoc.common.doc.ExtraInfoItem;
import ru.yandex.autodoc.common.doc.annotation.Description;
import ru.yandex.autodoc.common.doc.params.ParamDescriptor;
import ru.yandex.autodoc.common.doc.params.ParamType;
import ru.yandex.webmaster3.core.WebmasterException;
import ru.yandex.webmaster3.core.http.autodoc.ClassShapeRetriever;
import ru.yandex.webmaster3.core.http.autodoc.FullTypeInfo;
import ru.yandex.webmaster3.core.http.internal.ActionReflectionUtils;
import ru.yandex.webmaster3.core.http.internal.ParameterInfo;
import ru.yandex.webmaster3.core.util.ReflectionUtils;
import ru.yandex.webmaster3.core.util.joda.jackson.WebmasterDurationModule;

/**
 * @author aherman
 */
public class RequestConverter {
    public static final String MIME_APPLICATION_JSON = "application/json";
    public static final String MIME_MULTIPART_FORM_DATA = "multipart/form-data";
    public static final String MIME_APPLICATION_OCTET_STREAM = "application/octet-stream";

    private static final ObjectMapper OM = new ObjectMapper()
            .configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false)
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
            .registerModule(new JodaModule())
            .registerModule(new WebmasterJsonModule(false))
            .registerModule(new ParameterNamesModule())
            .registerModule(new Jdk8Module())
            .registerModule(new WebmasterDurationModule(false));

    private final IdentityHashMap<Class, ParameterConverter> converters;
    private final ParameterConverter enumParameterConverter = new EnumParameterConverter();
    private final ClassShapeRetriever classShapeRetriever = new ClassShapeRetriever(OM, ClassShapeRetriever.ObjectType.REQUEST);

    public RequestConverter(IdentityHashMap<Class, ParameterConverter> converters) {
        this.converters = converters;
        fillDefaultConverter();
    }

    public static RequestConverter create(Map<Class<?>, ParameterConverter> converters) {
        return new RequestConverter(new IdentityHashMap<>(converters));
    }

    void fillDefaultConverter() {
        PrimitiveParameterConverter primitiveParameterConverter = new PrimitiveParameterConverter();

        addConverterIfAbsent(String.class, primitiveParameterConverter);

        addConverterIfAbsent(Boolean.class, primitiveParameterConverter);
        addConverterIfAbsent(Boolean.TYPE, primitiveParameterConverter);

        addConverterIfAbsent(Integer.class, primitiveParameterConverter);
        addConverterIfAbsent(Integer.TYPE, primitiveParameterConverter);

        addConverterIfAbsent(Long.class, primitiveParameterConverter);
        addConverterIfAbsent(Long.TYPE, primitiveParameterConverter);

        addConverterIfAbsent(Float.class, primitiveParameterConverter);
        addConverterIfAbsent(Float.TYPE, primitiveParameterConverter);

        addConverterIfAbsent(Double.class, primitiveParameterConverter);
        addConverterIfAbsent(Double.TYPE, primitiveParameterConverter);

        DateTimeParameterConverter dateTimeParameterConverter = new DateTimeParameterConverter();
        addConverterIfAbsent(Instant.class, dateTimeParameterConverter);
        addConverterIfAbsent(DateTime.class, dateTimeParameterConverter);
        addConverterIfAbsent(LocalDate.class, dateTimeParameterConverter);

        addConverterIfAbsent(UUID.class, new UUIDParameterConverter());
        addConverterIfAbsent(URL.class, new URLParameterConverter());
    }

    void addConverterIfAbsent(Class<?> clazz, ParameterConverter converter) {
        if (converters.containsKey(clazz)) {
            return;
        }
        converters.put(clazz, converter);
    }

    public ActionResponse.ErrorResponse fillRequest(Object requestObject, HttpServletRequest httpRequest) {
        JsonNode jsonNode = null;
        if (StringUtils.startsWith(httpRequest.getContentType(), MIME_APPLICATION_JSON)) {
            BufferedReader reader = null;
            try {
                reader = httpRequest.getReader();
                jsonNode = OM.readTree(reader);
            } catch (IOException e) {
                return new WebmasterErrorResponse.UnableToReadJsonRequestResponse(this.getClass(), e);
            } finally {
                IOUtils.closeQuietly(reader);
            }
        }
        boolean isMultipart = StringUtils.startsWith(httpRequest.getContentType(), MIME_MULTIPART_FORM_DATA);
        Class<?> requestClass = requestObject.getClass();
        ActionResponse.ErrorResponse actionError = fillData(requestObject, httpRequest, jsonNode, requestClass, isMultipart);

        if (actionError != null) {
            return actionError;
        }

        if (requestObject instanceof BinaryActionRequest) {
            try {
                ServletInputStream inputStream = httpRequest.getInputStream();
                ((BinaryActionRequest) requestObject).setContentInputStream(inputStream);
            } catch (IOException e) {
                return new WebmasterErrorResponse.UnableToReadRequestBinaryDataResponse(this.getClass(), e);
            }
        }
        return null;
    }

    private ActionResponse.ErrorResponse fillData(Object requestObject, HttpServletRequest httpRequest, JsonNode jsonNode,
                                                Class<?> requestClass, boolean isMultipart) {
        Collection<ParameterInfo> properties = ActionReflectionUtils.getAllRequestParameters(requestClass);
        for (ParameterInfo parameter : properties) {
            FullTypeInfo parameterType = parameter.type;
            Class<?> parameterClass = parameterType.getClazz();
            Method method = parameter.method;
            String parameterName = parameter.name;
            switch (parameter.propertyType) {
                case FILE:
                    if (parameterClass != FileParameter.class) {
                        return new WebmasterErrorResponse.UnsupportedRequestParameterTypeResponse(this.getClass(), parameterName);
                    }
                    if (isMultipart) {
                        try {
                            Part part = httpRequest.getPart(parameterName);
                            if (part != null) {
                                FileParameter fileParameter = new FileParameter(part);
                                if (parameter.maxSize > 0) {
                                    if (part.getSize() > parameter.maxSize) {
                                        return new WebmasterErrorResponse.IllegalFileParameterResponse(this.getClass(), parameterName,
                                                "File size limit exceeded : name=" + parameterName + " class=" + parameterClass
                                                        + " maxSize=" + parameter.maxSize);
                                    }
                                }
                                method.invoke(requestObject, fileParameter);
                            }
                        } catch (IOException | ServletException e) {
                            return new WebmasterErrorResponse.IllegalParameterValueResponse(this.getClass(), parameterName, null);
                        } catch (InvocationTargetException | IllegalAccessException e) {
                            return new WebmasterErrorResponse.IllegalFileParameterResponse(this.getClass(), parameterName,
                                    "Unable to set file parameter: name=" + parameterName + " class=" + parameterClass);
                        }
                        continue;
                    }
                    break;
                case GET:
                    if (parameterClass.isArray()) {
                        if (setArrayFromQuery(requestObject, method, parameterName, parameterClass, httpRequest)) {
                            continue;
                        }
                    }
                    if (Collection.class.isAssignableFrom(parameterClass)) {
                        if (setCollectionFromQuery(requestObject, method, parameterName, parameterType, httpRequest)) {
                            continue;
                        }
                    }
                    if (setValueFromQuery(requestObject, method, parameterName, parameterClass, httpRequest)) {
                        continue;
                    }
                case JSON:
                    if (jsonNode != null && setValueFromJson(requestObject, method, parameterName, parameterType,
                            jsonNode)) {
                        continue;
                    }
            }
            if (parameter.required) {
                return new WebmasterErrorResponse.IllegalParameterValueResponse(this.getClass(), parameterName, null);
            }
        }

        return null;
    }

    private boolean setCollectionFromQuery(Object requestObject, Method method, String parameterName, FullTypeInfo parameterType,
                                           HttpServletRequest httpRequest) {
        String[] parameterValues = httpRequest.getParameterValues(parameterName);
        if (ArrayUtils.isEmpty(parameterValues)) {
            return false;
        }

        FullTypeInfo elementType = parameterType.ancestorFullType(Collection.class).getGenericsList().get(0);

        ParameterConverter converter = getConverter(elementType.getClazz());
        if (converter == null) {
            return false;
        }

        Collection result = ReflectionUtils.newCollection(parameterType.getClazz());
        for (int i = 0; i < parameterValues.length; i++) {
            String valueStr = StringUtils.trimToEmpty(parameterValues[i]);
            if (valueStr.isEmpty()) {
                continue;
            }
            Object value = convertValue(parameterName, elementType.getClazz(), valueStr);
            result.add(value);
        }
        try {
            method.invoke(requestObject, result);
            return true;
        } catch (IllegalAccessException | InvocationTargetException e) {
            throw new WebmasterException("Unable to set parameter: name=" + parameterName,
                    new WebmasterErrorResponse.IllegalParameterValueResponse(this.getClass(), parameterName, null), e);
        }
    }

    private boolean setArrayFromQuery(Object requestObject, Method method, String parameterName, Class<?> parameterType,
                                      HttpServletRequest httpRequest) {
        Class<?> componentType = parameterType.getComponentType();
        String[] parameterValues = httpRequest.getParameterValues(parameterName);
        if (ArrayUtils.isEmpty(parameterValues)) {
            return false;
        }
        ParameterConverter converter = getConverter(componentType);
        if (converter == null) {
            return false;
        }

        Object result = Array.newInstance(componentType, parameterValues.length);
        for (int i = 0; i < parameterValues.length; i++) {
            String valueStr = StringUtils.trimToEmpty(parameterValues[i]);
            if (valueStr.isEmpty()) {
                continue;
            }
            Object value = convertValue(parameterName, componentType, valueStr);
            Array.set(result, i, value);
        }
        try {
            method.invoke(requestObject, result);
            return true;
        } catch (IllegalAccessException | InvocationTargetException e) {
            throw new WebmasterException("Unable to set parameter: name=" + parameterName,
                    new WebmasterErrorResponse.IllegalParameterValueResponse(this.getClass(), parameterName, null), e);
        }
    }

    public List<ParamDescriptor> describeParameters(Class<?> reqClass) {
        List<ParamDescriptor> result = new ArrayList<>();
        Object example;
        try {
            example = reqClass.newInstance();
        } catch (InstantiationException | IllegalAccessException e) {
            throw new WebmasterException("Unable to instantiate request: " + e.getMessage(),
                    new WebmasterErrorResponse.UnableToInstantateRequestResponse(RequestConverter.class));
        }
        Collection<ParameterInfo> parameters = ActionReflectionUtils.getAllRequestParameters(reqClass);
        for (ParameterInfo info : parameters) {
            String defaultValue = null;
            if (!info.required) {
                Method getter = ReflectionUtils.getGetterForName(reqClass, info.internalName);
                try {
                    Object defaultValueObj;
                    if (getter != null) {
                        defaultValueObj = getter.invoke(example);
                    } else {
                        defaultValueObj = reqClass.getField(info.internalName).get(example);
                    }
                    defaultValue = defaultValueObj == null ? null : defaultValueObj.toString();
                } catch (IllegalAccessException | InvocationTargetException | NoSuchFieldException e) {
                    //ignore
                }
            }
            List<Description> descAnnotations = info.getAnnotations(Description.class);
            String description = null;
            if (descAnnotations != null && !descAnnotations.isEmpty()) {
                description = descAnnotations.get(0).value();
            }
            ParamType paramType = null;
            if (info.propertyType == ParameterInfo.PropertyType.FILE) {
                paramType = new ParamType("file");
            } else {
                ParameterConverter converter = getConverter(info.type.getClazz());
                if (converter != null) {
                    paramType = converter.describeType(info.type.getOriginalType());
                } else if (info.type.getClazz().isArray()) {
                    Class<?> elementType = info.type.getClazz().getComponentType();
                    converter = getConverter(elementType);
                    if (converter != null) {
                        ParamType elementTypeDesc = converter.describeType(elementType);
                        paramType = elementTypeDesc == null ? null : ParamType.array(elementTypeDesc);
                    }
                } else if (info.propertyType == ParameterInfo.PropertyType.GET &&
                        Collection.class.isAssignableFrom(info.type.getClazz())) {
                    Class<?> elementType = info.type.ancestorFullType(Collection.class).getGenericsList().get(0).getClazz();
                    converter = getConverter(elementType);
                    if (converter != null) {
                        ParamType elementTypeDesc = converter.describeType(elementType);
                        paramType = elementTypeDesc == null ? null : ParamType.array(elementTypeDesc);
                    }
                }
                if (paramType == null) {
                    paramType = ParamType.json(classShapeRetriever.retrieve(info.type));
                }
            }
            result.add(new ParamDescriptor(info.name, info.required, paramType, defaultValue, description,
                    Collections.<ExtraInfoItem>emptyList()));
        }
        return result;
    }

    private boolean setValueFromJson(Object requestObject, Method method, String parameterName,
                                     FullTypeInfo parameterType, JsonNode jsonData) {
        Object value = extractJsonValue(parameterName, parameterType, jsonData);
        if (value == null) {
            return false;
        }
        try {
            method.invoke(requestObject, value);
            return true;
        } catch (IllegalAccessException | InvocationTargetException e) {
            throw new WebmasterException("Unable to set parameter: name=" + parameterName,
                    new WebmasterErrorResponse.IllegalParameterValueResponse(this.getClass(), parameterName, null, e.getMessage()), e);
        }
    }

    private Object extractJsonValue(String parameterName, FullTypeInfo parameterType, JsonNode jsonData) {
        JsonNode valueNode = jsonData.get(parameterName);
        if (valueNode == null || valueNode.isNull()) {
            return null;
        }

        Class<?> parameterClass = parameterType.getClazz();

        if (valueNode.isValueNode()) {
            return convertValue(parameterName, parameterClass, valueNode.asText());
        }

        if (parameterClass.isArray()) {
            return getArray(parameterName, parameterClass, valueNode);
        }

        if (valueNode.isObject() || valueNode.isArray()) {
            return extractJsonValueWithJackson(parameterName, parameterType, valueNode);
        }

        throw new WebmasterException("Unknown parameter type: name=" + parameterName,
                new WebmasterErrorResponse.IllegalParameterValueResponse(this.getClass(), parameterName, null));
    }

    private Object extractJsonValueWithJackson(String parameterName, FullTypeInfo parameterType, JsonNode node) {
        try {
            return OM.readValue(new TreeTraversingParser(node), fullType2JacksonType(parameterType));
        } catch (IOException e) {
            throw new WebmasterException("Unable to set parameter: name=" + parameterName,
                    new WebmasterErrorResponse.IllegalParameterValueResponse(this.getClass(), parameterName, null, e.getMessage()), e);
        }
    }

    private Object getArray(String parameterName, Class<?> parameterType, JsonNode jsonData) {
        if (!jsonData.isArray()) {
            return null;
        }
        ArrayNode arrayData = (ArrayNode) jsonData;
        Class<?> componentType = parameterType.getComponentType();
        Object result = Array.newInstance(componentType, arrayData.size());

        ParameterConverter converter = getConverter(parameterType);
        if (converter != null) {
            for (int i = 0; i < arrayData.size(); i++) {
                Object value = convertValue(parameterName, componentType, arrayData.get(i).asText(), converter);
                Array.set(result, i, value);
            }
            return result;
        }

        for (int i = 0; i < arrayData.size(); i++) {
            try {
                Object value = OM.readValue(new TreeTraversingParser(arrayData.get(i)), componentType);
                Array.set(result, i, value);
            } catch (IOException e) {
                throw new WebmasterException("Unable to set parameter: name=" + parameterName,
                        new WebmasterErrorResponse.IllegalParameterValueResponse(this.getClass(), parameterName, null, e.getMessage()), e);
            }
        }

        return result;
    }

    private boolean setValueFromQuery(Object requestObject, Method method, String parameterName, Class<?> parameterType,
                                      HttpServletRequest httpRequest) {
        String valueStr = StringUtils.trimToNull(httpRequest.getParameter(parameterName));
        if (valueStr == null) {
            return false;
        }

        Object value = convertValue(parameterName, parameterType, valueStr);
        Class<?> valueType = value.getClass();
        try {
            method.invoke(requestObject, value);
            return true;
        } catch (IllegalAccessException | InvocationTargetException | IllegalArgumentException e) {
            throw new WebmasterException("Unable to set parameter: name=" + parameterName + " valueType=" + valueType,
                    new WebmasterErrorResponse.IllegalParameterValueResponse(this.getClass(), parameterName, valueStr), e);
        }
    }

    private ParameterConverter getConverter(Class<?> parameterType) {
        if (parameterType.isEnum()) {
            return enumParameterConverter;
        } else {
            return converters.get(parameterType);
        }
    }

    @NotNull
    private Object convertValue(String parameterName, Class<?> parameterType, String valueStr) {
        ParameterConverter converter = getConverter(parameterType);
        if (converter == null) {
            throw new WebmasterException("Unsupported parameter type: name=" + parameterName,
                    new WebmasterErrorResponse.UnsupportedRequestParameterTypeResponse(this.getClass(), parameterName));
        }
        return convertValue(parameterName, parameterType, valueStr, converter);
    }

    @NotNull
    private Object convertValue(String parameterName, Class<?> parameterType, String valueStr, ParameterConverter converter) {
        try {
            Object value = converter.convert(parameterName, valueStr, parameterType);

            if (value == null) {
                throw new WebmasterException("Unable to convert parameter: name=" + parameterName,
                        new WebmasterErrorResponse.IllegalParameterValueResponse(this.getClass(), parameterName, valueStr));
            }
            return value;
        } catch (IllegalArgumentException e) {
            throw new WebmasterException("Unable to convert parameter: name=" + parameterName,
                    new WebmasterErrorResponse.IllegalParameterValueResponse(this.getClass(), parameterName, valueStr),
                    e);
        }
    }

    private static JavaType fullType2JacksonType(FullTypeInfo typeInfo) {
        if (typeInfo.getGenericsList().isEmpty()) {
            return OM.constructType(typeInfo.getOriginalType());
        } else {
            List<JavaType> args = typeInfo.getGenericsList().stream().map(RequestConverter::fullType2JacksonType).collect(Collectors.toList());
            return OM.getTypeFactory().constructParametricType(typeInfo.getClazz(), args.toArray(new JavaType[args.size()]));
        }
    }
}
