/*
 * Copyright 2016 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * Licensed 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.keycloak.client.registration.cli.util;

import com.fasterxml.jackson.core.JsonParseException;
import org.keycloak.client.registration.cli.common.AttributeKey;
import org.keycloak.client.registration.cli.common.AttributeOperation;
import org.keycloak.util.JsonSerialization;

import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

/**
 * @author <a href="mailto:mstrukel@redhat.com">Marko Strukelj</a>
 */
public class ReflectionUtil {

    static Map<Class, Map<String, Field>> index = new HashMap<>();

    static void populateAttributesIndex(Class type) {
        // We are using fields rather than getters / setters
        // because it seems like JSON mapping sometimes also uses fields as well
        // This may have to be changed some day due to reliance on Field.setAccessible()
        Map<String, Field> map = new HashMap<>();
        Field [] fields  = type.getDeclaredFields();
        for (Field f: fields) {
            // make sure to also have access to non-public fields
            f.setAccessible(true);
            map.put(f.getName(), f);
        }
        index.put(type, map);
    }

    public static Map<String, Field> getAttrFieldsForType(Type gtype) {
        Class type;
        if (gtype instanceof Class) {
            type = (Class) gtype;
        } else if (gtype instanceof ParameterizedType) {
            type = (Class) ((ParameterizedType) gtype).getRawType();
        } else {
            throw new RuntimeException("Unexpected type: " + gtype);
        }

        if (isListType(type) || isMapType(type)) {
            return Collections.emptyMap();
        }
        Map<String, Field> map = index.get(type);
        if (map == null) {
            populateAttributesIndex(type);
            map = index.get(type);
        }
        return map;
    }

    public static boolean isListType(Class type) {
        return List.class.isAssignableFrom(type) || type.isArray();
    }

    public static boolean isBasicType(Type type) {
        return type == String.class || type == Boolean.class || type == boolean.class
                || type == Integer.class || type == int.class || type == Long.class || type == long.class
                || type == Float.class || type == float.class || type == Double.class || type == double.class;
    }

    public static boolean isMapType(Class type) {
        return Map.class.isAssignableFrom(type);
    }

    public static Object convertValueToType(Object value, Class<?> type) throws IOException {

        if (value == null) {
            return null;

        } else if (value instanceof String) {
            if (type == String.class) {
                return value;
            } else if (type == Boolean.class) {
                return Boolean.valueOf((String) value);
            } else if (type == Integer.class) {
                return Integer.valueOf((String) value);
            } else if (type == Long.class) {
                return Long.valueOf((String) value);
            } else {
                return JsonSerialization.readValue((String) value, type);
            }
        } else if (value instanceof Number) {
            if (type == Integer.class) {
                return ((Number) value).intValue();
            } else if (type == Long.class) {
                return ((Long) value).longValue();
            } else if (type == String.class) {
                return String.valueOf(value);
            }
        } else if (value instanceof Boolean) {
            if (type == Boolean.class) {
                return value;
            } else if (type == String.class) {
                return String.valueOf(value);
            }
        }

        throw new RuntimeException("Unable to handle type [" + type + "]");
    }

    public static void setAttributes(Object client, List<AttributeOperation> attrs) {

        for (AttributeOperation item: attrs) {

            AttributeKey attr = item.getKey();
            Object nested = client;

            List<AttributeKey.Component> cs = attr.getComponents();
            for (int i = 0; i < cs.size(); i++) {
                AttributeKey.Component c = cs.get(i);

                Class type = nested.getClass();
                Field field = null;

                if (!isMapType(type)) {
                    Map<String, Field> fields = getAttrFieldsForType(type);
                    if (fields == null) {
                        throw new AttributeException(attr.toString(), "Unexpected condition - unknown type: " + type);
                    }

                    field = fields.get(c.getName());
                    Class parent = type;
                    while (field == null) {
                        parent = parent.getSuperclass();
                        if (parent == Object.class) {
                            throw new AttributeException(attr.toString(), "Unknown attribute '" + c.getName() + "' on " + client.getClass());
                        }

                        fields = getAttrFieldsForType(parent);
                        field = fields.get(c.getName());
                    }
                }
                // if it's a 'basic' type we directly use setter
                type = field == null ? type : field.getType();
                if (isBasicType(type)) {
                    if (i < cs.size() - 1) {
                        throw new AttributeException(attr.toString(), "Attribute is of primitive type, and can't be nested further: " + c);
                    }

                    try {
                        Object val = convertValueToType(item.getValue(), type);
                        field.set(nested, val);
                    } catch (Exception e) {
                        throw new AttributeException(attr.toString(), "Failed to set attribute " + attr, e);
                    }
                } else if (isListType(type)) {
                    if (i < cs.size() -1) {
                        // not the target component
                        try {
                            nested = field.get(nested);
                        } catch (Exception e) {
                            throw new AttributeException(attr.toString(), "Failed to get attribute \"" + c + "\" in " + attr, e);
                        }
                        if (c.getIndex() >= 0) {
                            // list item
                            // get idx-th item
                            List l = (List) nested;
                            if (c.getIndex() >= l.size()) {
                                throw new AttributeException(attr.toString(), "Array index out of bounds for \"" + c + "\" in " + attr);
                            }
                            nested = l.get(c.getIndex());
                        }
                    } else {
                        // target component
                        Class itype = type;
                        Type gtype = field.getGenericType();
                        if (gtype instanceof ParameterizedType) {
                            Type[] typeArgs = ((ParameterizedType) gtype).getActualTypeArguments();
                            if (typeArgs.length >= 1 && typeArgs[0] instanceof Class) {
                                itype = (Class) typeArgs[0];
                            } else {
                                itype = String.class;
                            }
                        }
                        if (c.getIndex() >= 0 || attr.isAppend()) {
                            // some list item
                            // get the list first
                            List target;
                            try {
                                target = (List) field.get(nested);
                            } catch (Exception e) {
                                throw new AttributeException(attr.toString(), "Failed to get list attribute: " + attr, e);
                            }

                            // now replace or add idx-th item
                            if (target == null) {
                                target = createNewList(type);
                                try {
                                    field.set(nested, target);
                                } catch (Exception e) {
                                    throw new AttributeException(attr.toString(), "Failed to set list attribute " + attr, e);
                                }
                            }
                            if (c.getIndex() >= target.size()) {
                                throw new AttributeException(attr.toString(), "Array index out of bounds for \"" + c + "\" in " + attr);
                            }

                            if (attr.isAppend()) {
                                try {
                                    Object value = convertValueToType(item.getValue(), itype);
                                    if (c.getIndex() >= 0) {
                                        target.add(c.getIndex(), value);
                                    } else {
                                        target.add(value);
                                    }
                                } catch (Exception e) {
                                    throw new AttributeException(attr.toString(), "Failed to set list attribute " + attr, e);
                                }

                            } else {
                                if (item.getType() == AttributeOperation.Type.SET) {
                                    try {
                                        Object value = convertValueToType(item.getValue(), itype);
                                        target.set(c.getIndex(), value);
                                    } catch (Exception e) {
                                        throw new AttributeException(attr.toString(), "Failed to set list attribute " + attr, e);
                                    }
                                } else {
                                    try {
                                        target.remove(c.getIndex());
                                    } catch (Exception e) {
                                        throw new AttributeException(attr.toString(), "Failed to remove list attribute " + attr, e);
                                    }
                                }
                            }

                        } else {
                            // set the whole list field itself
                            List value = createNewList(type);;
                            if (item.getType() == AttributeOperation.Type.SET) {
                                List converted = convertValueToList(item.getValue(), itype);
                                value.addAll(converted);
                            }
                            try {
                                field.set(nested, value);
                            } catch (Exception e) {
                                throw new AttributeException(attr.toString(), "Failed to set list attribute " + attr, e);
                            }
                        }
                    }
                } else {
                    // object type
                    if (i < cs.size() -1) {
                        // not the target component
                        Object value;
                        if (field == null) {
                            if (isMapType(nested.getClass())) {
                                value = ((Map) nested).get(c.getName());
                            } else {
                                throw new RuntimeException("Unexpected condition while processing: " + attr);
                            }
                        } else {
                            try {
                                value = field.get(nested);
                            } catch (Exception e) {
                                throw new AttributeException(attr.toString(), "Failed to get attribute \"" + c + "\" in " + attr, e);
                            }
                        }
                        if (value == null) {
                            // create the target attribute
                            if (isMapType(nested.getClass())) {
                                throw new RuntimeException("Creating nested object trees not supported");
                            } else {
                                try {
                                    value = createNewObject(type);
                                    field.set(nested, value);
                                } catch (Exception e) {
                                    throw new AttributeException(attr.toString(), "Failed to set attribute " + attr, e);
                                }
                            }
                        }
                        nested = value;
                    } else {
                        // target component
                        // todo implement map put
                        if (isMapType(nested.getClass())) {
                            try {
                                ((Map) nested).put(c.getName(), item.getValue());
                            } catch (Exception e) {
                                throw new AttributeException(attr.toString(), "Failed to set map key " + attr, e);
                            }
                        } else {
                            try {
                                Object value = convertValueToType(item.getValue(), type);
                                field.set(nested, value);
                            } catch (Exception e) {
                                throw new AttributeException(attr.toString(), "Failed to set attribute " + attr, e);
                            }
                        }
                    }
                }
            }
        }
    }

    private static Object createNewObject(Class type) throws Exception {
        return type.newInstance();
    }

    public static List createNewList(Class type) {

        if (type == List.class) {
            return new ArrayList();
        } else if (type.isInterface()) {
            throw new RuntimeException("Can't instantiate a list type: " + type);
        }

        try {
            return (List) type.newInstance();
        } catch (Exception e) {
            throw new RuntimeException("Failed to instantiate a list type: " + type, e);
        }
    }

    public static List convertValueToList(String value, Class itemType) {
        try {
            List result = new LinkedList();
            if (!value.startsWith("[")) {
                throw new RuntimeException("List attribute value has to start with '[' - '" + value + "'");
            }
            List parsed = JsonSerialization.readValue(value, List.class);
            for (Object item: parsed) {
                if (itemType.isAssignableFrom(item.getClass())) {
                    result.add(item);
                } else {
                    result.add(convertValueToType(item, itemType));
                }
            }
            return result;

        } catch (JsonParseException e) {
            throw new RuntimeException("Failed to parse list value: " + e.getMessage(), e);
        } catch (IOException e) {
            throw new RuntimeException("Failed to parse list value: " + value, e);
        }
    }

    public static <T> void merge(T source, T dest) {
        // Use existing index for type, then iterate over all attributes and
        // use setter on dest, and getter on source to copy value over
        Map<String, Field> fieldMap = getAttrFieldsForType(source.getClass());
        try {
            for (String attrName : fieldMap.keySet()) {
                Field field = fieldMap.get(attrName);
                Object localValue = field.get(source);
                if (localValue != null) {
                    field.set(dest, localValue);
                }
            }
        } catch (Exception e) {
            throw new RuntimeException("Failed to merge changes", e);
        }
    }


    public static LinkedHashMap<String, String> getAttributeListWithJSonTypes(Class type, AttributeKey attr) {

        LinkedHashMap<String, String> result = new LinkedHashMap<>();
        attr = attr != null ? attr : new AttributeKey();

        Map<String, Field> fields = getAttrFieldsForType(type);
        for (AttributeKey.Component c: attr.getComponents()) {
            Field f = fields.get(c.getName());
            if (f == null) {
                throw new AttributeException(attr.toString(), "No such attribute: " + attr);
            }

            type = f.getType();
            if (isBasicType(type) || isListType(type) || isMapType(type)) {
                return result;
            } else {
                fields = getAttrFieldsForType(type);
            }
        }

        for (Map.Entry<String, Field> item : fields.entrySet()) {
            String key = item.getKey();
            Class clazz = item.getValue().getType();
            String t = getTypeString(clazz, item.getValue());

            result.put(key, t);
        }
        return result;
    }

    public static Field resolveField(Class type, AttributeKey attr) {
        Field f = null;
        Type gtype = type;

        for (AttributeKey.Component c: attr.getComponents()) {
            if (f != null) {
                gtype = f.getGenericType();
                if (gtype instanceof ParameterizedType) {
                    Type[] typeargs = ((ParameterizedType) gtype).getActualTypeArguments();
                    if (typeargs.length > 0) {
                        gtype = typeargs[typeargs.length-1];
                    }
                }
            }
            Map<String, Field> fields = getAttrFieldsForType(gtype);
            f = fields.get(c.getName());
            if (f == null) {
                throw new AttributeException(attr.toString(), "No such attribute: " + attr);
            }
        }
        return f;
    }

    public static String getTypeString(Type type, Field field) {
        Class clazz = null;
        if (type == null) {
            if (field == null) {
                throw new IllegalArgumentException("type == null and field == null");
            }
            type = field.getGenericType();
        }
        if (type instanceof Class) {
            clazz = (Class) type;
        } else if (type instanceof ParameterizedType) {
            StringBuilder sb = new StringBuilder();
            String rtype = getTypeString(((ParameterizedType) type).getRawType(), null);

            sb.append(rtype);
            sb.append(" ").append("(");
            Type[] typeArgs = ((ParameterizedType) type).getActualTypeArguments();

            for (int i = 0; i < typeArgs.length; i++) {
                if (i > 0) {
                    sb.append(", ");
                }
                sb.append(getTypeString(typeArgs[i], null));
            }
            sb.append(")");
            return sb.toString();
        }

        if (CharSequence.class.isAssignableFrom(clazz)) {
            return "string";
        } else if (Integer.class.isAssignableFrom(clazz) || int.class.isAssignableFrom(clazz)) {
            return "int";
        } else if (Long.class.isAssignableFrom(clazz) || long.class.isAssignableFrom(clazz)) {
            return "long";
        } else if (Float.class.isAssignableFrom(clazz) || float.class.isAssignableFrom(clazz)) {
            return "float";
        } else if (Double.class.isAssignableFrom(clazz) || double.class.isAssignableFrom(clazz)) {
            return "double";
        } else if (Number.class.isAssignableFrom(clazz)) {
            return "number";
        } else if (Boolean.class.isAssignableFrom(clazz) || boolean.class.isAssignableFrom(clazz)) {
            return "boolean";
        } else if (isListType(clazz)) {
            if (field != null) {
                Type gtype = field.getGenericType();
                if (gtype == clazz && clazz.isArray()) {
                    return "array (" + getTypeString(clazz.getComponentType(), null) + ")";
                }
                return getTypeString(gtype, null);
            }
            return "array";
        } else if (isMapType(clazz)) {
            if (field != null) {
                Type gtype = field.getGenericType();
                return getTypeString(gtype, null);
            }
            return "object";
        } else {
            return "object";
        }
    }
}
