/**
 * Copyright 2016 Red Hat, Inc. and/or its affiliates.
 *
 * Licensed under the Eclipse Public License version 1.0, available at
 * http://www.eclipse.org/legal/epl-v10.html
 */
package org.jboss.forge.addon.scaffold.faces.metawidget.inspector.propertystyle;

import java.io.FileNotFoundException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Array;
import java.lang.reflect.InvocationHandler;
import java.text.MessageFormat;
import java.util.List;
import java.util.Map;

import org.jboss.forge.addon.parser.java.facets.JavaSourceFacet;
import org.jboss.forge.addon.projects.Project;
import org.jboss.forge.addon.resource.ResourceException;
import org.jboss.forge.roaster.Roaster;
import org.jboss.forge.roaster.model.EnumConstant;
import org.jboss.forge.roaster.model.Field;
import org.jboss.forge.roaster.model.FieldHolder;
import org.jboss.forge.roaster.model.JavaClass;
import org.jboss.forge.roaster.model.JavaEnum;
import org.jboss.forge.roaster.model.Method;
import org.jboss.forge.roaster.model.MethodHolder;
import org.jboss.forge.roaster.model.Parameter;
import org.jboss.forge.roaster.model.Type;
import org.jboss.forge.roaster.model.source.JavaClassSource;
import org.jboss.forge.roaster.model.source.JavaSource;
import org.metawidget.inspector.iface.InspectorException;
import org.metawidget.inspector.impl.propertystyle.BaseProperty;
import org.metawidget.inspector.impl.propertystyle.BasePropertyStyle;
import org.metawidget.inspector.impl.propertystyle.Property;
import org.metawidget.inspector.impl.propertystyle.ValueAndDeclaredType;
import org.metawidget.util.ClassUtils;
import org.metawidget.util.CollectionUtils;
import org.metawidget.util.simple.StringUtils;

/**
 * Inspects Forge-specific <tt>JavaSource</tt> objects for properties.
 *
 * @author Richard Kennard
 */

public class ForgePropertyStyle
         extends BasePropertyStyle
{
   //
   // Private members
   //

   private final Project project;

   private final MessageFormat privateFieldConvention;

   //
   // Constructor
   //

   public ForgePropertyStyle(final ForgePropertyStyleConfig config)
   {
      super(config);

      this.project = config.getProject();
      this.privateFieldConvention = config.getPrivateFieldConvention();
   }

   //
   // Public methods
   //

   /**
    * Traverses the given Class heirarchy using properties of the given names.
    *
    * @return the declared type (not actual type). May be null
    */

   @Override
   public ValueAndDeclaredType traverse(final Object toTraverse, final String type, final boolean onlyToParent,
            final String... names)
   {
      // Traverse through names (if any)

      if ((names == null) || (names.length == 0))
      {
         // If no names, no parent

         if (onlyToParent)
         {
            return new ValueAndDeclaredType(null, null);
         }

         return new ValueAndDeclaredType(null, type);
      }

      String traverseDeclaredType = type;

      for (int loop = 0, length = names.length; loop < length; loop++)
      {
         if (onlyToParent && (loop >= (length - 1)))
         {
            return new ValueAndDeclaredType(null, traverseDeclaredType);
         }

         String name = names[loop];
         Property property = getProperties(traverseDeclaredType).get(name);

         if ((property == null) || !property.isReadable())
         {
            return new ValueAndDeclaredType(null, null);
         }

         traverseDeclaredType = property.getType();
      }

      return new ValueAndDeclaredType(null, traverseDeclaredType);
   }

   //
   // Protected methods
   //

   @Override
   protected Map<String, Property> inspectProperties(final String type)
   {
      try
      {
         // LinkedHashMap so that returns ordered properties

         Map<String, Property> properties = CollectionUtils.newLinkedHashMap();

         // Lookup properties

         inspectClassProperties(type, properties);

         return properties;
      }
      catch (Exception e)
      {
         throw InspectorException.newException(e);
      }
   }

   /**
    * Recursive lookup for properties from superclass in order to support inheritance
    */
   private void inspectClassProperties(final String type,
            Map<String, Property> properties)
   {

      JavaSource<?> clazz = sourceForName(this.project, type);

      if (clazz instanceof MethodHolder<?>)
      {
         lookupGetters(properties, (MethodHolder<?>) clazz);
         lookupSetters(properties, (MethodHolder<?>) clazz);

         if (clazz instanceof JavaClass)
         {
            JavaClassSource source = Roaster.parse(JavaClassSource.class,
                     clazz.toString());
            if (!source.getSuperType().equals("java.lang.Object"))
            {
               inspectClassProperties(source.getSuperType(), properties);
            }
         }
      }
   }

   /**
    * Lookup getter-based properties.
    * <p>
    * This method will be called after <code>lookupFields</code> but before <code>lookupSetters</code>.
    */

   protected void lookupGetters(final Map<String, Property> properties, final MethodHolder<?> clazz)
   {
      // Hack until https://issues.jboss.org/browse/FORGE-368

      for (Method<?, ?> method : clazz.getMethods())
      {
         // Exclude static methods

         if (method.isStatic())
         {
            continue;
         }

         // Exclude non-public methods. Section 8.3.1 of the JavaBean specification requires that getters be public.
         if (!method.isPublic())
         {
            continue;
         }

         // Get type

         if (!method.getParameters().isEmpty())
         {
            continue;
         }

         String returnType = method.getReturnType() != null ? method.getReturnType().getQualifiedName() : null;

         if (returnType == null)
         {
            continue;
         }

         // Get name

         String propertyName = isGetter(method);

         if (propertyName == null)
         {
            continue;
         }

         Field<?> privateField = getPrivateField((FieldHolder<?>) clazz, propertyName);

         if (privateField != null && this.privateFieldConvention == null)
         {
            propertyName = privateField.getName();
         }

         properties
                  .put(propertyName,
                           new ForgeProperty(propertyName, returnType, method, null, privateField, this.project));
      }
   }

   /**
    * Returns whether the given method is a 'getter' method.
    *
    * @param method a parameterless method that returns a non-void
    * @return the property name
    */

   protected String isGetter(final Method<?, ?> method)
   {

      String methodName = method.getName();
      String propertyName;

      if (methodName.startsWith(ClassUtils.JAVABEAN_GET_PREFIX))
      {
         propertyName = methodName.substring(ClassUtils.JAVABEAN_GET_PREFIX.length());

      }
      else if (methodName.startsWith(ClassUtils.JAVABEAN_IS_PREFIX)
               && method.getReturnType() != null && boolean.class.equals(method.getReturnType().getQualifiedName()))
      {

         // As per section 8.3.2 (Boolean properties) of The JavaBeans API specification, 'is'
         // only applies to boolean (little 'b')

         propertyName = methodName.substring(ClassUtils.JAVABEAN_IS_PREFIX.length());
      }
      else
      {
         return null;
      }

      return StringUtils.decapitalize(propertyName);
   }

   /**
    * Lookup setter-based properties.
    * <p>
    * This method will be called after <code>lookupFields</code> and <code>lookupGetters</code>.
    */

   protected void lookupSetters(final Map<String, Property> properties,
            final MethodHolder<?> clazz)
   {
      for (Method<?, ?> method : clazz.getMethods())
      {
         // Exclude static methods

         if (method.isStatic())
         {
            continue;
         }

         // Exclude non-public methods. Section 8.3.1 of the JavaBean specification requires that setters be public.
         if (!method.isPublic())
         {
            continue;
         }

         // Get type

         List<? extends Parameter<?>> parameters = method.getParameters();

         if (parameters.size() != 1)
         {
            continue;
         }

         // Get name

         String propertyName = isSetter(method);

         if (propertyName == null)
         {
            continue;
         }

         // Exclude based on other criteria
         //
         // (explicitly set to null in case we encountered an imbalanced field/getter)

         String type = parameters.get(0).getType().getQualifiedName();

         Field<?> privateField = getPrivateField((FieldHolder<?>) clazz, propertyName);

         if (privateField != null && this.privateFieldConvention == null)
         {
            propertyName = privateField.getName();
         }

         // Already found via its getter?

         Property existingProperty = properties.get(propertyName);

         if (existingProperty instanceof ForgeProperty)
         {
            ForgeProperty existingForgeProperty = (ForgeProperty) existingProperty;

            // Beware covariant return types: always prefer the getter's type

            properties.put(
                     propertyName,
                     new ForgeProperty(propertyName, existingForgeProperty.getType(),
                              existingForgeProperty.getReadMethod(), method, getPrivateField((FieldHolder<?>) clazz,
                                       propertyName), this.project));
            continue;
         }

         // Explicitly excluded based on getter already?

         if ((existingProperty == null) && properties.containsKey(propertyName))
         {
            continue;
         }

         properties
                  .put(propertyName,
                           new ForgeProperty(propertyName, type, null, method, privateField, this.project));
      }
   }

   /**
    * Returns whether the given method is a 'setter' method.
    *
    * @param method a single-parametered method. May return non-void (ie. for Fluent interfaces)
    * @return the property name
    */

   protected String isSetter(final Method<?, ?> method)
   {
      String methodName = method.getName();

      if (!methodName.startsWith(ClassUtils.JAVABEAN_SET_PREFIX))
      {
         return null;
      }

      String propertyName = methodName.substring(ClassUtils.JAVABEAN_SET_PREFIX.length());

      return StringUtils.decapitalize(propertyName);
   }

   /**
    * Gets the private field representing the given <code>propertyName</code> within the given class.
    *
    * @return the private Field for this propertyName, or null if no such field (should not throw NoSuchFieldException)
    */

   protected Field<?> getPrivateField(final FieldHolder<?> fieldHolder, final String propertyName)
   {
      if (this.privateFieldConvention != null)
      {
         // Determine field name based on convention. MessageFormat arguments are:
         //
         // {0} = dateOfBirth, surname
         // {1} = DateOfBirth, Surname

         String[] arguments = new String[] { propertyName, StringUtils.capitalize(propertyName) };
         String fieldName;

         synchronized (this.privateFieldConvention)
         {
            fieldName = this.privateFieldConvention.format(arguments, new StringBuffer(), null).toString();
         }

         return fieldHolder.getField(fieldName);
      }

      Field<?> field = fieldHolder.getField(propertyName);

      // FORGE-402: support fields starting with capital letter

      if (field == null && !Character.isUpperCase(propertyName.charAt(0)))
      {
         field = fieldHolder.getField(StringUtils.capitalize(propertyName));
      }

      return field;
   }

   //
   // Private methods
   //

   /* package private */

   static JavaSource<?> sourceForName(final Project project, final String type)
   {
      try
      {
         JavaSourceFacet javaSourceFact = project.getFacet(JavaSourceFacet.class);
         return javaSourceFact.getJavaResource(type).getJavaType();
      }
      catch (FileNotFoundException e)
      {
         // Not a Forge-based type

         return null;
      }
      catch (ResourceException e)
      {
         // Not a Forge-based type

         return null;
      }
   }

   //
   // Inner classes
   //

   public static class ForgeProperty
            extends BaseProperty
   {
      //
      // Private methods
      //

      private final Method<?, ?> readMethod;

      private final Method<?, ?> writeMethod;

      private final Field<?> privateField;

      private final Project project;

      //
      // Constructor
      //

      public ForgeProperty(final String name, final String type, final Method<?, ?> readMethod,
               final Method<?, ?> writeMethod,
               final Field<?> privateField,
               final Project project)
      {
         super(name, type);

         this.readMethod = readMethod;
         this.writeMethod = writeMethod;

         // Must have a getter or a setter (or both)

         if (this.readMethod == null && this.writeMethod == null)
         {
            throw InspectorException.newException("Property '" + name + "' has no getter and no setter");
         }

         this.privateField = privateField;
         this.project = project;
      }

      //
      // Public methods
      //

      @Override
      public boolean isReadable()
      {
         return (this.readMethod != null);
      }

      @Override
      public Object read(final Object obj)
      {
         throw new UnsupportedOperationException();
      }

      @Override
      public boolean isWritable()
      {
         return (this.writeMethod != null);
      }

      @Override
      public void write(Object obj, Object value)
      {
         throw new UnsupportedOperationException();
      }

      @Override
      public <T extends Annotation> T getAnnotation(final Class<T> annotationClass)
      {
         org.jboss.forge.roaster.model.Annotation<?> annotation = null;

         // https://issues.jboss.org/browse/FORGE-439: support annotations on readMethod

         if (this.readMethod != null)
         {
            annotation = this.readMethod.getAnnotation(annotationClass.getName());
         }

         if (annotation == null && this.privateField != null)
         {
            annotation = this.privateField.getAnnotation(annotationClass.getName());
         }

         if (annotation != null)
         {
            T annotationProxy = AnnotationProxy.newInstance(annotation);
            return annotationProxy;
         }

         return null;
      }

      @SuppressWarnings({ "rawtypes", "unchecked" })
      public List<EnumConstant<?>> getEnumConstants()
      {
         JavaSource<?> source = sourceForName(this.project, getType());

         if (source instanceof JavaEnum)
         {
            return ((JavaEnum) source).getEnumConstants();
         }

         return null;
      }

      @Override
      public String getGenericType()
      {
         if (this.readMethod != null)
         {
            @SuppressWarnings({ "unchecked", "rawtypes" })
            List<Type<?>> typeArguments = (List) this.readMethod.getReturnType().getTypeArguments();

            if (!typeArguments.isEmpty())
            {
               return typeArguments.get(0).getQualifiedName();
            }
         }

         if (this.privateField != null)
         {
            @SuppressWarnings({ "unchecked", "rawtypes" })
            List<Type<?>> typeArguments = (List) this.privateField.getType().getTypeArguments();

            if (!typeArguments.isEmpty())
            {
               return typeArguments.get(0).getQualifiedName();
            }
         }

         return null;
      }

      public Method<?, ?> getReadMethod()
      {
         return this.readMethod;
      }

      public Method<?, ?> getWriteMethod()
      {
         return this.writeMethod;
      }
   }

   /**
    * Java annotations are defined as interfaces. Therefore in order to instantiate one, we must create a Proxy.
    */

   public static class AnnotationProxy<T extends Annotation> implements InvocationHandler
   {
      //
      // Private statics
      //

      private final org.jboss.forge.roaster.model.Annotation<?> annotationSource;

      private final Class<T> annotationClass;

      //
      // Public statics
      //

      @SuppressWarnings("unchecked")
      public static <T extends Annotation> T newInstance(
               final org.jboss.forge.roaster.model.Annotation<?> annotationSource)
      {
         try
         {
            Class<T> annotationClass = (Class<T>) Class.forName(annotationSource.getQualifiedName());

            // TODO: test this not using annotationSource.getClass().getClassLoader() (will require integration test)

            return (T) java.lang.reflect.Proxy.newProxyInstance(
                     annotationClass.getClassLoader(),
                     new Class[] { annotationClass },
                     new AnnotationProxy<T>(annotationClass, annotationSource));
         }
         catch (Exception e)
         {
            throw InspectorException.newException(e);
         }
      }

      //
      // Constructor
      //

      private AnnotationProxy(final Class<T> annotationClass,
               final org.jboss.forge.roaster.model.Annotation<?> annotationSource)
      {
         this.annotationSource = annotationSource;
         this.annotationClass = annotationClass;
      }

      //
      // Public methods
      //

      @Override
      public Object invoke(final Object proxy, final java.lang.reflect.Method method, final Object[] args)
               throws Throwable
      {
         try
         {
            String methodName = method.getName();

            // Reserved name

            if ("annotationType".equals(methodName))
            {
               return this.annotationClass;
            }

            // If no value, return the default...

            java.lang.reflect.Method annotationMethod = this.annotationClass.getMethod(methodName);
            String literalValue = this.annotationSource.getLiteralValue(methodName);

            if (literalValue == null)
            {
               Object defaultValue = annotationMethod.getDefaultValue();

               if (defaultValue == null)
               {
                  throw new UnsupportedOperationException(methodName + " does not have a default value");
               }

               return defaultValue;
            }

            // ...otherwise parse it

            return parse(literalValue, annotationMethod.getReturnType());
         }
         catch (Exception e)
         {
            throw InspectorException.newException(e);
         }
      }

      //
      // Private methods
      //

      /**
       * Parses the given literal value into the given returnType. Supports all standard annotation types (JLS 9.7).
       */

      private Object parse(String literalValue, Class<?> returnType) throws ClassNotFoundException
      {
         // Primitives

         if (byte.class.equals(returnType))
         {
            return Byte.valueOf(literalValue);
         }
         if (short.class.equals(returnType))
         {
            return Short.valueOf(literalValue);
         }
         if (int.class.equals(returnType))
         {
            return Integer.valueOf(literalValue);
         }
         if (long.class.equals(returnType))
         {
            String valueToUse = literalValue;
            if (valueToUse.endsWith("l") || valueToUse.endsWith("L"))
            {
               valueToUse = valueToUse.substring(0, valueToUse.length() - 1);
            }
            return Long.valueOf(valueToUse);
         }
         if (float.class.equals(returnType))
         {
            String valueToUse = literalValue;
            if (valueToUse.endsWith("f") || valueToUse.endsWith("F"))
            {
               valueToUse = valueToUse.substring(0, valueToUse.length() - 1);
            }
            return Float.valueOf(valueToUse);
         }
         if (double.class.equals(returnType))
         {
            String valueToUse = literalValue;
            if (valueToUse.endsWith("d") || valueToUse.endsWith("D"))
            {
               valueToUse = literalValue.substring(0, valueToUse.length() - 1);
            }
            return Double.valueOf(valueToUse);
         }
         if (boolean.class.equals(returnType))
         {
            return Boolean.valueOf(literalValue);
         }
         if (char.class.equals(returnType))
         {
            return Character.valueOf(literalValue.charAt(1));
         }

         // Arrays

         if (returnType.isArray())
         {
            String[] values = literalValue.substring(1, literalValue.length() - 1).split(",");
            int length = values.length;
            Class<?> componentType = returnType.getComponentType();
            Object array = Array.newInstance(componentType, length);

            for (int loop = 0; loop < length; loop++)
            {
               Array.set(array, loop, parse(values[loop], componentType));
            }

            return array;
         }

         // Enums

         if (returnType.isEnum())
         {
            Enum<?>[] constants = (Enum<?>[]) returnType.getEnumConstants();

            String valueToUse = StringUtils.substringAfterLast(literalValue, '.');

            for (Enum<?> inst : constants)
            {
               if (inst.name().equals(valueToUse))
               {
                  return inst;
               }
            }

            return null;
         }

         // Strings

         if (String.class.equals(returnType))
         {
            return literalValue.substring(1, literalValue.length() - 1);
         }

         // Classes

         if (Class.class.equals(returnType))
         {
            String resolvedType = StringUtils.substringBefore(literalValue, ".class");
            resolvedType = ((JavaSource<?>) this.annotationSource.getOrigin()).resolveType(resolvedType);
            return Class.forName(resolvedType);
         }

         // Annotations

         if (Annotation.class.isAssignableFrom(returnType))
         {
            String resolvedType = StringUtils.substringAfter(literalValue, "@");
            resolvedType = ((JavaSource<?>) this.annotationSource.getOrigin()).resolveType(resolvedType);

            return AnnotationProxy.newInstance(this.annotationSource);
         }

         // Unknown

         throw new UnsupportedOperationException(returnType.getSimpleName());
      }
   }
}
