/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *  http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License 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 org.apache.struts2.json;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.json.annotations.JSON;

import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.reflect.*;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalQuery;
import java.util.*;

/**
 * Isolate the process of populating JSON objects from the Interceptor class
 * itself.
 */
public class JSONPopulator {

    private static final Logger LOG = LogManager.getLogger(JSONPopulator.class);

    private String dateFormat = JSONUtil.RFC3339_FORMAT;

    public JSONPopulator() {
    }

    public JSONPopulator(String dateFormat) {
        this.dateFormat = dateFormat;
    }

    public String getDateFormat() {
        return dateFormat;
    }

    public void setDateFormat(String dateFormat) {
        this.dateFormat = dateFormat;
    }

    public void populateObject(Object object, final Map elements) throws IllegalAccessException,
            InvocationTargetException, NoSuchMethodException, IntrospectionException,
            IllegalArgumentException, JSONException, InstantiationException {
        Class<?> clazz = object.getClass();

        BeanInfo info = Introspector.getBeanInfo(clazz);
        PropertyDescriptor[] props = info.getPropertyDescriptors();

        // iterate over class fields
        for (PropertyDescriptor prop : props) {
            String name = prop.getName();

            if (elements.containsKey(name)) {
                Object value = elements.get(name);
                Method method = prop.getWriteMethod();

                if (method != null) {
                    JSON json = method.getAnnotation(JSON.class);
                    if ((json != null) && !json.deserialize()) {
                        continue;
                    }

                    // use only public setters
                    if (Modifier.isPublic(method.getModifiers())) {
                        Class<?>[] paramTypes = method.getParameterTypes();
                        Type[] genericTypes = method.getGenericParameterTypes();

                        if (paramTypes.length == 1) {
                            Object convertedValue = this.convert(paramTypes[0], genericTypes[0], value, method);
                            method.invoke(object, convertedValue);
                        }
                    }
                }
            }
        }
    }

    public Object convert(Class<?> clazz, Type type, Object value, Method method)
            throws IllegalArgumentException, JSONException, IllegalAccessException,
            InvocationTargetException, InstantiationException, NoSuchMethodException, IntrospectionException {

        if (value == null) {
            // if it is a java primitive then get a default value, otherwise
            // leave it as null
            return clazz.isPrimitive() ? convertPrimitive(clazz, value, method) : null;
        } else if (isJSONPrimitive(clazz))
            return convertPrimitive(clazz, value, method);
        else if (Collection.class.isAssignableFrom(clazz))
            return convertToCollection(clazz, type, value, method);
        else if (Map.class.isAssignableFrom(clazz))
            return convertToMap(clazz, type, value, method);
        else if (clazz.isArray())
            return convertToArray(clazz, type, value, method);
        else if (value instanceof Map) {
            // nested bean
            Object convertedValue = clazz.getDeclaredConstructor().newInstance();
            this.populateObject(convertedValue, (Map) value);
            return convertedValue;
        } else if (BigDecimal.class.equals(clazz)) {
            return new BigDecimal(value.toString());
        } else if (BigInteger.class.equals(clazz)) {
            return new BigInteger(value.toString());
        } else
            throw new JSONException("Incompatible types for property " + method.getName());
    }

    private static boolean isJSONPrimitive(Class<?> clazz) {
        return clazz.isPrimitive() || clazz.equals(String.class) || clazz.equals(Date.class)
                || clazz.equals(Boolean.class) || clazz.equals(Byte.class) || clazz.equals(Character.class)
                || clazz.equals(Double.class) || clazz.equals(Float.class) || clazz.equals(Integer.class)
                || clazz.equals(Long.class) || clazz.equals(Short.class) || clazz.equals(Locale.class)
                || clazz.isEnum()
                || Calendar.class.isAssignableFrom(clazz)
                || clazz.equals(LocalDate.class) || clazz.equals(LocalDateTime.class)
                || clazz.equals(LocalTime.class) || clazz.equals(ZonedDateTime.class)
                || clazz.equals(OffsetDateTime.class) || clazz.equals(Instant.class);
    }

    private Object convertToArray(Class<?> clazz, Type type, Object value, Method accessor)
            throws JSONException, IllegalArgumentException, IllegalAccessException,
            InvocationTargetException, InstantiationException, NoSuchMethodException, IntrospectionException {
        if (value == null)
            return null;
        else if (value instanceof List) {
            Class<?> arrayType = clazz.getComponentType();
            List values = (List) value;
            Object newArray = Array.newInstance(arrayType, values.size());

            // create an object for each element
            for (int j = 0; j < values.size(); j++) {
                Object listValue = values.get(j);

                if (arrayType.equals(Object.class)) {
                    // Object[]
                    Array.set(newArray, j, listValue);
                } else if (isJSONPrimitive(arrayType)) {
                    // primitive array
                    Array.set(newArray, j, this.convertPrimitive(arrayType, listValue, accessor));
                } else if (listValue instanceof Map) {
                    // array of other class
                    Object newObject;
                    if (Map.class.isAssignableFrom(arrayType)) {
                        newObject = convertToMap(arrayType, type, listValue, accessor);
                    } else if (List.class.isAssignableFrom(arrayType)) {
                        newObject = convertToCollection(arrayType, type, listValue, accessor);
                    } else {
                        newObject = arrayType.getDeclaredConstructor().newInstance();
                        this.populateObject(newObject, (Map) listValue);
                    }

                    Array.set(newArray, j, newObject);
                } else
                    throw new JSONException("Incompatible types for property " + accessor.getName());
            }

            return newArray;
        } else
            throw new JSONException("Incompatible types for property " + accessor.getName());
    }

    @SuppressWarnings("unchecked")
    private Object convertToCollection(Class<?> clazz, Type type, Object value, Method accessor)
            throws JSONException, IllegalArgumentException, IllegalAccessException,
            InvocationTargetException, InstantiationException, NoSuchMethodException, IntrospectionException {
        if (value == null)
            return null;
        else if (value instanceof List) {
            Class<?> itemClass = Object.class;
            Type itemType = null;
            if (type instanceof ParameterizedType ptype) {
                itemType = ptype.getActualTypeArguments()[0];
                if (itemType.getClass().equals(Class.class)) {
                    itemClass = (Class) itemType;
                } else {
                    itemClass = (Class) ((ParameterizedType) itemType).getRawType();
                }
            }
            List values = (List) value;

            Collection newCollection;
            if (clazz.isInterface() || Modifier.isAbstract(clazz.getModifiers())) {
                if (SortedSet.class.isAssignableFrom(clazz)) {
                    newCollection = new TreeSet();
                } else if (Set.class.isAssignableFrom(clazz)) {
                    newCollection = new HashSet();
                } else if (Queue.class.isAssignableFrom(clazz)) {
                    newCollection = new ArrayDeque();
                } else {
                    newCollection = new ArrayList();
                }
            } else {
                newCollection = (Collection) clazz.getDeclaredConstructor().newInstance();
            }

            // create an object for each element
            for (Object listValue : values) {

                if (itemClass.equals(Object.class)) {
                    // Object[]
                    newCollection.add(listValue);
                } else if (isJSONPrimitive(itemClass)) {
                    // primitive array
                    newCollection.add(this.convertPrimitive(itemClass, listValue, accessor));
                } else if (Map.class.isAssignableFrom(itemClass)) {
                    Object newObject = convertToMap(itemClass, itemType, listValue, accessor);
                    newCollection.add(newObject);
                } else if (List.class.isAssignableFrom(itemClass)) {
                    Object newObject = convertToCollection(itemClass, itemType, listValue, accessor);
                    newCollection.add(newObject);
                } else if (listValue instanceof Map) {
                    // array of beans
                    Object newObject = itemClass.getDeclaredConstructor().newInstance();
                    this.populateObject(newObject, (Map) listValue);
                    newCollection.add(newObject);
                } else
                    throw new JSONException("Incompatible types for property " + accessor.getName());
            }

            return newCollection;
        } else
            throw new JSONException("Incompatible types for property " + accessor.getName());
    }

    @SuppressWarnings("unchecked")
    private Object convertToMap(Class<?> clazz, Type type, Object value, Method accessor) throws JSONException,
            IllegalArgumentException, IllegalAccessException, InvocationTargetException,
            InstantiationException, NoSuchMethodException, IntrospectionException {
        if (value == null)
            return null;
        else if (value instanceof Map) {
            Class<?> itemClass = Object.class;
            Type itemType = null;
            if (type instanceof ParameterizedType ptype) {
                itemType = ptype.getActualTypeArguments()[1];
                if (itemType.getClass().equals(Class.class)) {
                    itemClass = (Class) itemType;
                } else {
                    itemClass = (Class) ((ParameterizedType) itemType).getRawType();
                }
            }
            Map values = (Map) value;

            Map newMap;
            if (clazz.isInterface() || Modifier.isAbstract(clazz.getModifiers())) {
                if (SortedMap.class.isAssignableFrom(clazz)) {
                    newMap = new TreeMap();
                } else {
                    newMap = new HashMap();
                }
            } else {
                newMap = (Map) clazz.getDeclaredConstructor().newInstance();
            }

            // create an object for each element
            for (Object next : values.entrySet()) {
                Map.Entry entry = (Map.Entry) next;
                String key = (String) entry.getKey();
                Object v = entry.getValue();

                if (itemClass.equals(Object.class)) {
                    // String, Object
                    newMap.put(key, v);
                } else if (isJSONPrimitive(itemClass)) {
                    // primitive map
                    newMap.put(key, this.convertPrimitive(itemClass, v, accessor));
                } else if (Map.class.isAssignableFrom(itemClass)) {
                    Object newObject = convertToMap(itemClass, itemType, v, accessor);
                    newMap.put(key, newObject);
                } else if (List.class.isAssignableFrom(itemClass)) {
                    Object newObject = convertToCollection(itemClass, itemType, v, accessor);
                    newMap.put(key, newObject);
                } else if (v instanceof Map) {
                    // map of beans
                    Object newObject = itemClass.getDeclaredConstructor().newInstance();
                    this.populateObject(newObject, (Map) v);
                    newMap.put(key, newObject);
                } else
                    throw new JSONException("Incompatible types for property " + accessor.getName());
            }

            return newMap;
        } else
            throw new JSONException("Incompatible types for property " + accessor.getName());
    }

    /**
     * Converts numbers to the desired class, if possible
     *
     * @throws JSONException
     */
    @SuppressWarnings("unchecked")
    private Object convertPrimitive(Class clazz, Object value, Method method) throws JSONException {
        if (value == null) {
            if (Short.TYPE.equals(clazz) || Short.class.equals(clazz))
                return (short) 0;
            else if (Byte.TYPE.equals(clazz) || Byte.class.equals(clazz))
                return (byte) 0;
            else if (Integer.TYPE.equals(clazz) || Integer.class.equals(clazz))
                return 0;
            else if (Long.TYPE.equals(clazz) || Long.class.equals(clazz))
                return 0L;
            else if (Float.TYPE.equals(clazz) || Float.class.equals(clazz))
                return 0f;
            else if (Double.TYPE.equals(clazz) || Double.class.equals(clazz))
                return 0d;
            else if (Boolean.TYPE.equals(clazz) || Boolean.class.equals(clazz))
                return Boolean.FALSE;
            else
                return null;
        } else if (value instanceof Number) {
            Number number = (Number) value;

            if (Short.TYPE.equals(clazz))
                return number.shortValue();
            else if (Short.class.equals(clazz))
                return number.shortValue();
            else if (Byte.TYPE.equals(clazz))
                return number.byteValue();
            else if (Byte.class.equals(clazz))
                return number.byteValue();
            else if (Integer.TYPE.equals(clazz))
                return number.intValue();
            else if (Integer.class.equals(clazz))
                return number.intValue();
            else if (Long.TYPE.equals(clazz))
                return number.longValue();
            else if (Long.class.equals(clazz))
                return number.longValue();
            else if (Float.TYPE.equals(clazz))
                return number.floatValue();
            else if (Float.class.equals(clazz))
                return number.floatValue();
            else if (Double.TYPE.equals(clazz))
                return number.doubleValue();
            else if (Double.class.equals(clazz))
                return number.doubleValue();
            else if (String.class.equals(clazz))
                return value.toString();
        } else if (clazz.equals(Date.class)) {
            try {
                JSON json = method.getAnnotation(JSON.class);

                DateFormat formatter = new SimpleDateFormat(
                        (json != null) && (!json.format().isEmpty()) ? json.format() : this.dateFormat);
                return formatter.parse((String) value);
            } catch (ParseException e) {
                LOG.error("Unable to parse date from: {}", value, e);
                throw new JSONException("Unable to parse date from: " + value);
            }
        } else if (Calendar.class.isAssignableFrom(clazz)) {
            try {
                JSON json = method.getAnnotation(JSON.class);

                DateFormat formatter = new SimpleDateFormat(
                        (json != null) && (!json.format().isEmpty()) ? json.format() : this.dateFormat);
                Date date = formatter.parse((String) value);
                Calendar cal = Calendar.getInstance();
                cal.setTime(date);
                return cal;
            } catch (ParseException e) {
                LOG.error("Unable to parse calendar from: {}", value, e);
                throw new JSONException("Unable to parse calendar from: " + value);
            }
        } else if (clazz.equals(LocalDate.class)) {
            return parseTemporalFromString(value, method, DateTimeFormatter.ISO_LOCAL_DATE, LocalDate::from);
        } else if (clazz.equals(LocalDateTime.class)) {
            return parseTemporalFromString(value, method, DateTimeFormatter.ISO_LOCAL_DATE_TIME, LocalDateTime::from);
        } else if (clazz.equals(LocalTime.class)) {
            return parseTemporalFromString(value, method, DateTimeFormatter.ISO_LOCAL_TIME, LocalTime::from);
        } else if (clazz.equals(ZonedDateTime.class)) {
            return parseTemporalFromString(value, method, DateTimeFormatter.ISO_ZONED_DATE_TIME, ZonedDateTime::from);
        } else if (clazz.equals(OffsetDateTime.class)) {
            return parseTemporalFromString(value, method, DateTimeFormatter.ISO_OFFSET_DATE_TIME, OffsetDateTime::from);
        } else if (clazz.equals(Instant.class)) {
            return parseInstantFromString(value, method);
        } else if (clazz.isEnum()) {
            String sValue = (String) value;
            return Enum.valueOf(clazz, sValue);
        } else if (value instanceof String sValue) {
            if (Boolean.TYPE.equals(clazz))
                return Boolean.parseBoolean(sValue);
            else if (Boolean.class.equals(clazz))
                return Boolean.valueOf(sValue);
            else if (Short.TYPE.equals(clazz))
                return Short.parseShort(sValue);
            else if (Short.class.equals(clazz))
                return Short.valueOf(sValue);
            else if (Byte.TYPE.equals(clazz))
                return Byte.parseByte(sValue);
            else if (Byte.class.equals(clazz))
                return Byte.valueOf(sValue);
            else if (Integer.TYPE.equals(clazz))
                return Integer.parseInt(sValue);
            else if (Integer.class.equals(clazz))
                return Integer.valueOf(sValue);
            else if (Long.TYPE.equals(clazz))
                return Long.parseLong(sValue);
            else if (Long.class.equals(clazz))
                return Long.valueOf(sValue);
            else if (Float.TYPE.equals(clazz))
                return Float.parseFloat(sValue);
            else if (Float.class.equals(clazz))
                return Float.valueOf(sValue);
            else if (Double.TYPE.equals(clazz))
                return Double.parseDouble(sValue);
            else if (Double.class.equals(clazz))
                return Double.valueOf(sValue);
            else if (Character.TYPE.equals(clazz) || Character.class.equals(clazz)) {
                char charValue = 0;
                if (!sValue.isEmpty()) {
                    charValue = sValue.charAt(0);
                }
                if (Character.TYPE.equals(clazz))
                    return charValue;
                else
                    return charValue;
            } else if (clazz.equals(Locale.class)) {
                String[] components = sValue.split("_", 2);
                if (components.length == 2) {
                    return new Locale(components[0], components[1]);
                } else {
                    return new Locale(sValue);
                }
            } else if (Enum.class.isAssignableFrom(clazz)) {
                return Enum.valueOf(clazz, sValue);
            }
        }

        return value;
    }

    private <T> T parseTemporalFromString(Object value, Method method, DateTimeFormatter defaultFormatter, TemporalQuery<T> query) throws JSONException {
        try {
            String sValue = (String) value;
            JSON json = method.getAnnotation(JSON.class);

            DateTimeFormatter formatter;
            if (json != null && !json.format().isEmpty()) {
                formatter = DateTimeFormatter.ofPattern(json.format());
            } else {
                formatter = defaultFormatter;
            }
            return formatter.parse(sValue, query);
        } catch (Exception e) {
            LOG.error("Unable to parse temporal from: {}", value, e);
            throw new JSONException("Unable to parse temporal from: " + value);
        }
    }

    private Instant parseInstantFromString(Object value, Method method) throws JSONException {
        try {
            String sValue = (String) value;
            JSON json = method.getAnnotation(JSON.class);

            if (json != null && !json.format().isEmpty()) {
                DateTimeFormatter formatter = DateTimeFormatter.ofPattern(json.format()).withZone(ZoneOffset.UTC);
                return Instant.from(formatter.parse(sValue));
            }
            return Instant.parse(sValue);
        } catch (Exception e) {
            LOG.error("Unable to parse instant from: {}", value, e);
            throw new JSONException("Unable to parse instant from: " + value);
        }
    }

}
