/*
   Copyright The Narayana Authors
   SPDX short identifier: Apache-2.0
 */

package com.arjuna.common.internal.util.propertyservice;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

import com.arjuna.common.util.propertyservice.PropertiesFactory;

/**
 * Utility class that configures *EnvironmentBean objects using a PropertyManager, which is usually
 * backed by a -properties.xml file.
 *
 * @author Jonathan Halliday (jonathan.halliday@redhat.com)
 */
public class BeanPopulator
{
    private static final ConcurrentMap<String, Object> beanInstances = new ConcurrentHashMap<String, Object>();

    public static <T> T getDefaultInstance(Class<T> beanClass) throws RuntimeException {
        T instance = (T) beanInstances.get(beanClass.getName());

        if (instance != null)
           return instance;

        return getNamedInstance(beanClass, null, null);
    }

    public static <T> T getDefaultInstance(Class<T> beanClass, Properties properties) throws RuntimeException {
       T instance = (T) beanInstances.get(beanClass.getName());

        if (instance != null)
           return instance;

        return getNamedInstance(beanClass, null, properties);
    }

    public static <T> T getNamedInstance(Class<T> beanClass, String name) throws RuntimeException {
        return getNamedInstance(beanClass, name, null);
    }

    private static <T> T getNamedInstance(Class<T> beanClass, String name, Properties properties) throws RuntimeException {
        StringBuilder sb = new StringBuilder().append(beanClass.getName());

        if (name != null)
           sb.append(":").append(name);

        String key = sb.toString();

        // we don't mind sometimes instantiating the bean multiple times,
        // as long as the duplicates never escape into the outside world.
        if(!beanInstances.containsKey(key)) {
            T bean = null;
            try {
                bean = beanClass.newInstance();
                if (properties != null) {
                    configureFromProperties(bean, name, properties);
                } else {
                    Properties defaultProperties = PropertiesFactory.getDefaultProperties();
                    configureFromProperties(bean, name, defaultProperties);
                }
            } catch (Throwable e) {
                throw new RuntimeException(e);
            }
            beanInstances.putIfAbsent(key, bean);
        }

        return (T) beanInstances.get(key);
    }

    /**
     * @deprecated Only used in tests
     */
    public static void configureFromProperties(Object bean, Properties properties) throws Exception {
        configureFromProperties(bean, null, properties);
    }

    /**
     * Examine the properties of the provided bean and update them to match the values of the corresponding
     * properties in the Properties.
     * This will normally be used at startup to configure a freshly created default bean to match
     * the configuration read from a properties file.
     *
     * The algorithm is as follows: for each field in the bean, which must have a getter and setter method
     * matching according to the JavaBeans naming conventions, determine the corresponding property key.
     *
     * Several key names are tried, with the first match being used:
     * For scalar properties: The FQN of the bean class, optionally followed by the bean name, followed by the field name,
     *  e.g. com.arjuna.FooBean.theField or com.arjuna.FooBean.theName.theField
     *   the short class name of the bean, optionally followed by the bean name, followed by the field name,
     *  e.g. FooBean.theField or FooBean.theName.theField
     *  and finally the bean classes' PropertyPrefix annotation
     *   value followed by the name of the field, the last being except in cases where the field has a FullPropertyName
     *   annotation, in which case its value is used instead.
     * For vector (in the math sense - the type is actually normally List/Map) properties, a single property key matched
     *   according to the prior rules will be treated as having a compound value which will be tokenized into
     *   elements based on whitespace and inserted into the list in token order or further tokenized on = for Map key/value.
     * If no such key is found, the value of the field's ConcatenationPrefix annotation
     * is treated as a name prefix and the list elements are assembled from the values of any properties having
     * key values starting with the prefix. These are inserted into the list in order of the key's name sort position.
     *
     * This allows for the convention that all properties in a given bean will share
     * the same prefix e.g. com.arjuna.ats.arjuna.foo. whilst still allowing for changing of the property
     * name in cases where this makes for more readable code.
     *
     * Obtain the value of the property from the Properties and if it's not null, type convert it to match
     * the bean's property field type.  Obtain the value of the property from the bean and if it's different
     * from the value read from the Properties, use the setter to update the bean.
     *
     * @param bean a JavaBean, the target of the property updates and source for defaults.
     * @param instanceName the (optional, use null for default) name for the bean instance.
     * @param properties a Properties object, the source of the configuration overrides.
     * @throws Exception if the configuration of the bean fails.
     */
    public static void configureFromProperties(Object bean, String instanceName, Properties properties) throws Exception {

        for(Field field : bean.getClass().getDeclaredFields()) {
            Class type = field.getType();

            String setterMethodName = "set"+capitalizeFirstLetter(field.getName());
            Method setter;
            try {
                setter = bean.getClass().getMethod(setterMethodName, new Class[] {field.getType()});
            } catch(NoSuchMethodException e) {
                continue; // emma code coverage tool adds fields to instrumented classes - ignore them.
            }

            String getterMethodName;
            Method getter = null;
            if(field.getType().equals(Boolean.TYPE)) {
                getterMethodName = "is"+capitalizeFirstLetter(field.getName());
                try {
                    getter = bean.getClass().getMethod(getterMethodName, new Class[] {});
                } catch (NoSuchMethodException e) {}
            }

            if(getter == null) {
                getterMethodName = "get"+capitalizeFirstLetter(field.getName());
                getter = bean.getClass().getMethod(getterMethodName, new Class[] {});
            }

            if(field.isAnnotationPresent(ConcatenationPrefix.class) || field.getType().getName().startsWith("java.util")) {
                handleGroupProperty(bean, instanceName, properties, field, setter, getter);
            } else {
                handleSimpleProperty(bean, instanceName, properties, field, setter, getter);
            }
        }
    }

    /**
     * Attempt to set a named bean instance.
     *
     * [Please note that this method is not guaranteed to exist longer term since it is provided in an internal API.
     * In a later release a public API will be provided to support similar behaviour].
     *
     * If the name is not already associated with a bean instance (or is mapped to null) then the name will be associated
     * with the supplied bean and the method will return null, else the method returns the current instance
     * associated with the name.
     *
     * Once a bean is associated with a name any other configuration mechanism (such as XML property files and
     * system properties) will be ignored. Therefore this method can be useful if the caller wishes to avoid the
     * need to parse XML property files.
     *
     * @param name the name of the instance to set
     * @param beanInstance the instance to be associated with the name
     * @return the previous instance associated with the specified name, or null if there was no mapping for the name.
     */
    public static Object setBeanInstanceIfAbsent(String name, Object beanInstance) {
        return beanInstances.putIfAbsent(name, beanInstance);
    }

    public static String printBean(Object bean) {
        StringBuffer buffer = new StringBuffer();
        printBean(bean, buffer);
        return buffer.toString();
    }

    /**
     * Render the state of the known bean instances as text.
     */
    public static String printState() {
        StringBuffer buffer = new StringBuffer();
        for(Object bean : beanInstances.values()) {
            printBean(bean, buffer);
        }
        return buffer.toString();
    }

    private static void handleGroupProperty(Object bean, String instanceName, Properties properties, Field field, Method setter, Method getter)
        throws Exception
    {
        List<String> lines = new LinkedList<String>();

        String valueFromProperties = getValueFromProperties(bean, instanceName, properties, field, bean.getClass().getSimpleName());

        if(valueFromProperties != null)
        {
            // it's a single value which needs parsing

            String[] tokens = valueFromProperties.split("\\s+");

            // for lists, the order we want them in is the order they appear in the string, so we can just add in sequence:
            for(String token : tokens) {
                lines.add(token);
            }
        }
        else
        {
            // it's set of values that need gathering together
            // for lists, the order we want them in is the lex sort order of the keys, so we need to buffer and sort them:

            List<String> listOfMatchingPropertyNames = new LinkedList<String>();
            Enumeration propertyNamesEnumeration = properties.propertyNames();

            ConcatenationPrefix concatenationPrefix = field.getAnnotation(ConcatenationPrefix.class);
            if (propertyNamesEnumeration != null && concatenationPrefix != null)
            {
                String prefix = concatenationPrefix.prefix();

                while (propertyNamesEnumeration.hasMoreElements())
                {
                    String name = (String) propertyNamesEnumeration.nextElement();

                    if (name.startsWith(prefix))
                    {
                        listOfMatchingPropertyNames.add(name);
                    }
                }
            }

            Collections.sort(listOfMatchingPropertyNames);

            for(String name : listOfMatchingPropertyNames) {
                String value = properties.getProperty(name);
                lines.add(value);
            }

            if(lines.isEmpty()) {
                return; // no relevant value in properties file, so leave bean defaults alone.
            }
        }

        Object replacementValue = null;

        if(java.util.Map.class.isAssignableFrom(field.getType())) {
            // we have a list but need a map. split each element into key/value pair.
            Map<String, String> map = new HashMap<String, String>();
            for(String element : lines) {
                String[] tokens = element.split("=");
                map.put(tokens[0], tokens[1]);
            }
            replacementValue = map;
        } else {
             // it stays as a list.
            replacementValue = lines;
        }

        Object valueFromBean = getter.invoke(bean, new Object[] {});

        if(!valueFromBean.equals(replacementValue)) {
            setter.invoke(bean, new Object[] {replacementValue});
        }
    }

    private static void handleSimpleProperty(Object bean, String instanceName, Properties properties, Field field, Method setter, Method getter)
            throws Exception
    {
        String prefix = null;
        if(bean.getClass().isAnnotationPresent(PropertyPrefix.class)) {
            PropertyPrefix prefixAnnotation = bean.getClass().getAnnotation(PropertyPrefix.class);
            prefix = prefixAnnotation.prefix();
        }

        String valueFromProperties = getValueFromProperties(bean, instanceName, properties, field, prefix);

        if(valueFromProperties != null) {

            Object valueFromBean = getter.invoke(bean, new Object[] {});

            if(field.getType().equals(Boolean.TYPE)) {

                if(!((Boolean)valueFromBean).booleanValue() && isPositive(valueFromProperties)) {
                    setter.invoke(bean, new Object[]{ Boolean.TRUE });
                }

                if(((Boolean)valueFromBean).booleanValue() && isNegative(valueFromProperties)) {
                    setter.invoke(bean, new Object[] { Boolean.FALSE});
                }

            } else if(field.getType().equals(String.class)) {

                if(!valueFromProperties.equals(valueFromBean)) {
                    setter.invoke(bean, new Object[] {valueFromProperties});
                }

            } else if(field.getType().equals(Long.TYPE)) {

                Long longValue = Long.valueOf(valueFromProperties);
                if(!longValue.equals(valueFromBean)) {
                    setter.invoke(bean, new Object[] {longValue});
                }

            } else if(field.getType().equals(Integer.TYPE)) {

                Integer intValue = Integer.valueOf(valueFromProperties);
                if(!intValue.equals(valueFromBean)) {
                    setter.invoke(bean, new Object[] {intValue});
                }

            } else {

                throw new Exception("unknown field type "+field.getType());

            }
        }
    }

    private static String getValueFromProperties(Object bean, String instanceName, Properties properties, Field field, String prefix)
    {
        String propertyFileKey;
        String valueFromProperties = null;

        if(valueFromProperties == null) {

            if(instanceName == null) {
                propertyFileKey = bean.getClass().getName()+"."+field.getName();
                valueFromProperties = properties.getProperty(propertyFileKey);
            }

            if(valueFromProperties == null) {
                propertyFileKey = bean.getClass().getName()+"."+instanceName+"."+field.getName();
                valueFromProperties = properties.getProperty(propertyFileKey);
            }
        }

        if(valueFromProperties == null) {

            if(instanceName == null) {
                propertyFileKey = bean.getClass().getSimpleName()+"."+field.getName();
                valueFromProperties = properties.getProperty(propertyFileKey);
            }

            if(valueFromProperties == null) {
                propertyFileKey = bean.getClass().getSimpleName()+"."+instanceName+"."+field.getName();
                valueFromProperties = properties.getProperty(propertyFileKey);
            }
        }

        if (valueFromProperties == null) {

            if(field.isAnnotationPresent(FullPropertyName.class)) {
                FullPropertyName fullPropertyName = field.getAnnotation(FullPropertyName.class);
                propertyFileKey = fullPropertyName.name();
            } else if(prefix != null) {
                propertyFileKey = prefix+field.getName();
            } else {
                propertyFileKey = field.getName();
            }

            valueFromProperties = properties.getProperty(propertyFileKey);
        }

        return valueFromProperties;
    }

    private static void printBean(Object bean, StringBuffer buffer)
    {
        String lineSeparator = System.getProperty("line.separator");
        buffer.append("Bean class: ");
        buffer.append(bean.getClass().getName());
        buffer.append(lineSeparator);

        for(Field field : bean.getClass().getDeclaredFields()) {
            Class type = field.getType();

            String getterMethodName;
            Method getter = null;
            if(field.getType().equals(Boolean.TYPE)) {
                getterMethodName = "is"+capitalizeFirstLetter(field.getName());
                try {
                    getter = bean.getClass().getMethod(getterMethodName, new Class[] {});
                } catch (NoSuchMethodException e) {}
            }

            try {
                if(getter == null) {
                    getterMethodName = "get"+capitalizeFirstLetter(field.getName());
                    getter = bean.getClass().getMethod(getterMethodName, new Class[] {});
                }

                Object valueFromBean = getter.invoke(bean, new Object[] {});

                buffer.append("  ");
                buffer.append(field.getName());
                buffer.append(": ");
                buffer.append(valueFromBean);
            } catch(Exception e) {
                buffer.append("failed to read property ");
                buffer.append(field.getName());
            }
            buffer.append(lineSeparator);
        }
    }

    private static String capitalizeFirstLetter(String string) {
        return (string.length()>0) ? (Character.toUpperCase(string.charAt(0))+string.substring(1)) : string;
    }

    private static boolean isPositive(String value) {
        return "YES".equalsIgnoreCase(value) || "ON".equalsIgnoreCase(value) || "TRUE".equalsIgnoreCase(value);
    }

    private static boolean isNegative(String value) {
        return "NO".equalsIgnoreCase(value) || "OFF".equalsIgnoreCase(value) || "FALSE".equalsIgnoreCase(value);
    }
}