package org.xbib.logging.util;

import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.nio.charset.Charset;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.function.Supplier;
import java.util.logging.Level;
import java.util.regex.Pattern;
import org.xbib.logging.configuration.ContextConfiguration;

/**
 * Helper to lazily build an object.
 */
@SuppressWarnings({"UnusedReturnValue"})
public class ObjectBuilder<T> {

    private final ContextConfiguration contextConfiguration;

    private final Class<? extends T> baseClass;

    private final String className;

    private final Map<String, String> constructorProperties;

    private final Map<String, String> properties;

    private final Set<PropertyValue> definedProperties;

    private final Set<String> postConstructMethods;

    private ObjectBuilder(final ContextConfiguration contextConfiguration,
                          final Class<? extends T> baseClass, final String className) {
        this.contextConfiguration = contextConfiguration;
        this.baseClass = baseClass;
        this.className = className;
        constructorProperties = new LinkedHashMap<>();
        properties = new LinkedHashMap<>();
        definedProperties = new LinkedHashSet<>();
        postConstructMethods = new LinkedHashSet<>();
    }

    /**
     * Create a new {@link ObjectBuilder}.
     *
     * @param baseClass the base type
     * @param className the name of the class to create
     * @param <T>       the type being created
     * @return a new {@link ObjectBuilder}
     */
    public static <T> ObjectBuilder<T> of(final ContextConfiguration contextConfiguration,
                                   final Class<? extends T> baseClass, final String className) {
        return new ObjectBuilder<>(contextConfiguration, baseClass, className);
    }

    /**
     * Adds a property used for constructing the object.
     * <p>
     * The {@code name} must be the base name for a getter or setter so the type can be determined.
     * </p>
     *
     * @param name  the name of the property
     * @param value a string representing the value
     * @return this builder
     */
    public ObjectBuilder<T> addConstructorProperty(final String name, final String value) {
        constructorProperties.put(name, value);
        return this;
    }

    /**
     * Adds a method name to be executed after the object is created and the properties are set. The method must not
     * have any parameters.
     *
     * @param methodNames the name of the method to execute
     * @return this builder
     */
    public ObjectBuilder<T> addPostConstructMethods(final String... methodNames) {
        if (methodNames != null) {
            Collections.addAll(postConstructMethods, methodNames);
        }
        return this;
    }

    /**
     * Adds a property to be set on the object after it has been created.
     *
     * @param name  the name of the property
     * @param value a string representing the value
     * @return this builder
     */
    public ObjectBuilder<T> addProperty(final String name, final String value) {
        properties.put(name, value);
        return this;
    }

    /**
     * Adds a defined property to be set after the object is created.
     *
     * @param name  the name of the property
     * @param type  the type of the property
     * @param value a supplier for the property value
     * @return this builder
     */
    public ObjectBuilder<T> addDefinedProperty(final String name, final Class<?> type, final Supplier<?> value) {
        definedProperties.add(new PropertyValue(name, type, value));
        return this;
    }

    /**
     * Sets the name of the module used to load the object being created.
     *
     * @param moduleName the module name or {@code null} to use this class loader
     * @return this builder
     */
    public ObjectBuilder<T> setModuleName(final String moduleName) {
        return this;
    }

    /**
     * Creates a the object when the {@linkplain Supplier#get() supplier} value is accessed.
     *
     * @return a supplier which can create the object
     */
    public Supplier<T> build() {
        if (className == null) {
            throw new IllegalArgumentException("className is null");
        }
        final Map<String, String> constructorProperties = new LinkedHashMap<>(this.constructorProperties);
        final Map<String, String> properties = new LinkedHashMap<>(this.properties);
        final Set<String> postConstructMethods = new LinkedHashSet<>(this.postConstructMethods);
        return () -> {
            final ClassLoader classLoader = getClass().getClassLoader();
            final Class<? extends T> actualClass;
            try {
                actualClass = classLoader.loadClass(className).asSubclass(baseClass);
            } catch (Exception e) {
                throw new IllegalArgumentException(String.format("Failed to load class \"%s\"", className), e);
            }
            final int length = constructorProperties.size();
            final Class<?>[] paramTypes = new Class<?>[length];
            final Object[] params = new Object[length];
            int i = 0;
            for (Map.Entry<String, String> entry : constructorProperties.entrySet()) {
                final String property = entry.getKey();
                final Class<?> type = getConstructorPropertyType(actualClass, property);
                if (type == null) {
                    throw new IllegalArgumentException(
                            String.format("No property named \"%s\" in \"%s\"", property, className));
                }
                paramTypes[i] = type;
                params[i] = getValue(actualClass, property, type, entry.getValue());
                i++;
            }
            final Constructor<? extends T> constructor;
            try {
                constructor = actualClass.getConstructor(paramTypes);
            } catch (Exception e) {
                throw new IllegalArgumentException(String.format("Failed to locate constructor in class \"%s\"", className), e);
            }

            // Get all the setters
            final Map<Method, Object> setters = new LinkedHashMap<>();
            for (Map.Entry<String, String> entry : properties.entrySet()) {
                final Method method = getPropertySetter(actualClass, entry.getKey());
                if (method == null) {
                    throw new IllegalArgumentException(String
                            .format("Failed to locate setter for property \"%s\" on type \"%s\"", entry.getKey(), className));
                }
                // Get the value type for the setter
                Class<?> type = getPropertyType(method);
                if (type == null) {
                    throw new IllegalArgumentException(String
                            .format("Failed to determine type for setter \"%s\" on type \"%s\"", method.getName(), className));
                }
                setters.put(method, getValue(actualClass, entry.getKey(), type, entry.getValue()));
            }

            // Define known type parameters
            for (PropertyValue value : definedProperties) {
                final String methodName = getPropertySetterName(value.name);
                try {
                    final Method method = actualClass.getMethod(methodName, value.type);
                    setters.put(method, value.value.get());
                } catch (NoSuchMethodException e) {
                    throw new IllegalArgumentException(String.format(
                            "Failed to find setter method for property \"%s\" on type \"%s\"", value.name, className), e);
                }
            }

            // Get all the post construct methods
            final Set<Method> postConstruct = new LinkedHashSet<>();
            for (String methodName : postConstructMethods) {
                try {
                    postConstruct.add(actualClass.getMethod(methodName));
                } catch (NoSuchMethodException e) {
                    throw new IllegalArgumentException(
                            String.format("Failed to find post construct method \"%s\" on type \"%s\"", methodName, className),
                            e);
                }
            }
            try {
                T instance = constructor.newInstance(params);

                // Execute setters
                for (Map.Entry<Method, Object> entry : setters.entrySet()) {
                    entry.getKey().invoke(instance, entry.getValue());
                }

                // Execute post construct methods
                for (Method method : postConstruct) {
                    method.invoke(instance);
                }

                return instance;
            } catch (Exception e) {
                throw new IllegalArgumentException(String.format("Failed to instantiate class \"%s\"", className), e);
            }
        };
    }

    @SuppressWarnings("unchecked")
    private Object getValue(final Class<?> objClass, final String propertyName, final Class<?> paramType, final String value) {
        if (value == null) {
            if (paramType.isPrimitive()) {
                throw new IllegalArgumentException(
                        String.format("Cannot assign null value to primitive property \"%s\" of %s", propertyName, objClass));
            }
            return null;
        }
        final var trimmedValue = value.trim();
        if (paramType == String.class) {
            // Don't use the trimmed value for strings
            return value;
        } else if (paramType == Level.class) {
            return contextConfiguration.getContext().getLevelForName(trimmedValue);
        } else if (paramType == java.util.logging.Logger.class) {
            return contextConfiguration.getContext().getLogger(trimmedValue);
        } else if (paramType == boolean.class || paramType == Boolean.class) {
            return Boolean.valueOf(trimmedValue);
        } else if (paramType == byte.class || paramType == Byte.class) {
            return Byte.valueOf(trimmedValue);
        } else if (paramType == short.class || paramType == Short.class) {
            return Short.valueOf(trimmedValue);
        } else if (paramType == int.class || paramType == Integer.class) {
            return Integer.valueOf(trimmedValue);
        } else if (paramType == long.class || paramType == Long.class) {
            return Long.valueOf(trimmedValue);
        } else if (paramType == float.class || paramType == Float.class) {
            return Float.valueOf(trimmedValue);
        } else if (paramType == double.class || paramType == Double.class) {
            return Double.valueOf(trimmedValue);
        } else if (paramType == char.class || paramType == Character.class) {
            return !trimmedValue.isEmpty() ? trimmedValue.charAt(0) : 0;
        } else if (paramType == TimeZone.class) {
            return TimeZone.getTimeZone(trimmedValue);
        } else if (paramType == Charset.class) {
            return Charset.forName(trimmedValue);
        } else if (paramType.isAssignableFrom(Level.class)) {
            return Level.parse(trimmedValue);
        } else if (paramType.isEnum()) {
            return Enum.valueOf(paramType.asSubclass(Enum.class), trimmedValue);
        } else if (contextConfiguration.hasObject(trimmedValue)) {
            return contextConfiguration.getObject(trimmedValue);
        } else if (definedPropertiesContains(propertyName)) {
            final PropertyValue propertyValue = findDefinedProperty(propertyName);
            if (propertyValue == null) {
                throw new IllegalArgumentException("Unknown parameter type for property " + propertyName + " on " + objClass);
            }
            return propertyValue.value.get();
        } else {
            throw new IllegalArgumentException("Unknown parameter type for property " + propertyName + " on " + objClass);
        }
    }

    private boolean definedPropertiesContains(final String name) {
        return findDefinedProperty(name) != null;
    }

    private PropertyValue findDefinedProperty(final String name) {
        for (PropertyValue value : definedProperties) {
            if (name.equals(value.name)) {
                return value;
            }
        }
        return null;
    }

    private static Class<?> getPropertyType(Class<?> clazz, String propertyName) {
        return getPropertyType(getPropertySetter(clazz, propertyName));
    }

    private static Class<?> getPropertyType(final Method setter) {
        return setter != null ? setter.getParameterTypes()[0] : null;
    }

    private static Class<?> getConstructorPropertyType(Class<?> clazz, String propertyName) {
        final Method getter = getPropertyGetter(clazz, propertyName);
        return getter != null ? getter.getReturnType() : getPropertyType(clazz, propertyName);
    }

    private static Method getPropertySetter(Class<?> clazz, String propertyName) {
        final String set = getPropertySetterName(propertyName);
        for (Method method : clazz.getMethods()) {
            if ((method.getName().equals(set) && Modifier.isPublic(method.getModifiers()))
                    && method.getParameterTypes().length == 1) {
                return method;
            }
        }
        return null;
    }

    private static Method getPropertyGetter(Class<?> clazz, String propertyName) {
        final String upperPropertyName = Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1);
        final Pattern pattern = Pattern.compile("(get|has|is)(" + Pattern.quote(upperPropertyName) + ")");
        for (Method method : clazz.getMethods()) {
            if ((pattern.matcher(method.getName()).matches() && Modifier.isPublic(method.getModifiers()))
                    && method.getParameterTypes().length == 0) {
                return method;
            }
        }
        return null;
    }

    private static String getPropertySetterName(final String propertyName) {
        return "set" + Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1);
    }

    private static class PropertyValue implements Comparable<PropertyValue> {
        final String name;
        final Class<?> type;
        final Supplier<?> value;

        private PropertyValue(final String name, final Class<?> type, final Supplier<?> value) {
            this.name = name;
            this.type = type;
            this.value = value;
        }

        @Override
        public int compareTo(final PropertyValue o) {
            return name.compareTo(o.name);
        }
    }
}
