/*
 * 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.jboss.errai.codegen.util;

import static java.lang.reflect.Modifier.isPublic;

import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Collection;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.enterprise.util.Nonbinding;
import javax.inject.Named;
import javax.inject.Qualifier;

import org.apache.commons.lang3.AnnotationUtils;
import org.apache.commons.lang3.ClassUtils;
import org.apache.commons.lang3.Validate;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import org.jboss.errai.codegen.meta.MetaClass;
import org.jboss.errai.codegen.meta.MetaClassFactory;
import org.jboss.errai.codegen.meta.MetaMethod;
import org.jboss.errai.common.client.util.AnnotationPropertyAccessor;
import org.jboss.errai.common.client.util.AnnotationPropertyAccessorBuilder;
import org.jboss.errai.common.metadata.MetaDataScanner;
import org.jboss.errai.common.metadata.ScannerSingleton;

/**
 * <p>
 * Helper methods for working with {@link Annotation} instances.
 * </p>
 *
 * <p>
 * This class contains various utility methods that make working with
 * annotations simpler.
 * </p>
 *
 * <p>
 * This modified version of {@link AnnotationUtils} ignores {@link Nonbinding}
 * values when calculating hashcodes and equality, for comparing CDI qualifiers.
 * </p>
 *
 * <p>
 * {@link Annotation} instances are always proxy objects; unfortunately dynamic
 * proxies cannot be depended upon to know how to implement certain methods in
 * the same manner as would be done by "natural" {@link Annotation}s. The
 * methods presented in this class can be used to avoid that possibility. It is
 * of course also possible for dynamic proxies to actually delegate their e.g.
 * {@link Annotation#equals(Object)}/{@link Annotation#hashCode()}/
 * {@link Annotation#toString()} implementations to {@link CDIAnnotationUtils}.
 * </p>
 *
 * <p>
 * #ThreadSafe#
 * </p>
 *
 */
public class CDIAnnotationUtils {

    /**
     * A style that prints annotations as recommended.
     */
    private static final ToStringStyle TO_STRING_STYLE = new ToStringStyle() {
        /** Serialization version */
        private static final long serialVersionUID = 1L;

        {
            setDefaultFullDetail(true);
            setArrayContentDetail(true);
            setUseClassName(true);
            setUseShortClassName(true);
            setUseIdentityHashCode(false);
            setContentStart("(");
            setContentEnd(")");
            setFieldSeparator(", ");
            setArrayStart("[");
            setArrayEnd("]");
        }

        /**
         * {@inheritDoc}
         */
        @Override
        protected String getShortClassName(java.lang.Class<?> cls) {
            Class<? extends Annotation> annotationType = null;
            for (final Class<?> iface : ClassUtils.getAllInterfaces(cls)) {
                if (Annotation.class.isAssignableFrom(iface)) {
                    @SuppressWarnings("unchecked")
                    final
                    //because we just checked the assignability
                    Class<? extends Annotation> found = (Class<? extends Annotation>) iface;
                    annotationType = found;
                    break;
                }
            }
            return new StringBuilder(annotationType == null ? "" : annotationType.getName())
                    .insert(0, '@').toString();
        }

        /**
         * {@inheritDoc}
         */
        @Override
        protected void appendDetail(StringBuffer buffer, String fieldName, Object value) {
            if (value instanceof Annotation) {
                value = CDIAnnotationUtils.toString((Annotation) value);
            }
            super.appendDetail(buffer, fieldName, value);
        }

    };

    /**
     * <p>{@code AnnotationUtils} instances should NOT be constructed in
     * standard programming. Instead, the class should be used statically.</p>
     *
     * <p>This constructor is public to permit tools that require a JavaBean
     * instance to operate.</p>
     */
    public CDIAnnotationUtils() {
    }

    //-----------------------------------------------------------------------
    /**
     * <p>Checks if two annotations are equal using the criteria for equality
     * presented in the {@link Annotation#equals(Object)} API docs.</p>
     *
     * @param a1 the first Annotation to compare, {@code null} returns
     * {@code false} unless both are {@code null}
     * @param a2 the second Annotation to compare, {@code null} returns
     * {@code false} unless both are {@code null}
     * @return {@code true} if the two annotations are {@code equal} or both
     * {@code null}
     */
    public static boolean equals(Annotation a1, Annotation a2) {
        if (a1 == a2) {
            return true;
        }
        if (a1 == null || a2 == null) {
            return false;
        }
        final Class<? extends Annotation> type = a1.annotationType();
        final Class<? extends Annotation> type2 = a2.annotationType();
        Validate.notNull(type, "Annotation %s with null annotationType()", a1);
        Validate.notNull(type2, "Annotation %s with null annotationType()", a2);
        if (!type.equals(type2)) {
            return false;
        }
        try {
            for (final Method m : type.getDeclaredMethods()) {
                if (m.getParameterTypes().length == 0
                        && isValidAnnotationMemberType(m.getReturnType())
                        && !m.isAnnotationPresent(Nonbinding.class)) {
                    final Object v1 = m.invoke(a1);
                    final Object v2 = m.invoke(a2);
                    if (!memberEquals(m.getReturnType(), v1, v2)) {
                        return false;
                    }
                }
            }
        } catch (final IllegalAccessException ex) {
            return false;
        } catch (final InvocationTargetException ex) {
            return false;
        }
        return true;
    }

    /**
     * <p>Generate a hash code for the given annotation using the algorithm
     * presented in the {@link Annotation#hashCode()} API docs.</p>
     *
     * @param a the Annotation for a hash code calculation is desired, not
     * {@code null}
     * @return the calculated hash code
     * @throws RuntimeException if an {@code Exception} is encountered during
     * annotation member access
     * @throws IllegalStateException if an annotation method invocation returns
     * {@code null}
     */
    public static int hashCode(Annotation a) {
        int result = 0;
        final Class<? extends Annotation> type = a.annotationType();
        for (final Method m : type.getDeclaredMethods()) {
            if (!m.isAnnotationPresent(Nonbinding.class)) {
              try {
                final Object value = m.invoke(a);
                if (value == null) {
                  throw new IllegalStateException(
                          String.format("Annotation method %s returned null", m));
                }
                result += hashMember(m.getName(), value);
              } catch (final RuntimeException ex) {
                throw ex;
              } catch (final Exception ex) {
                throw new RuntimeException(ex);
              }
            }
        }
        return result;
    }

    /**
     * <p>Generate a string representation of an Annotation, as suggested by
     * {@link Annotation#toString()}.</p>
     *
     * @param a the annotation of which a string representation is desired
     * @return the standard string representation of an annotation, not
     * {@code null}
     */
    public static String toString(final Annotation a) {
        final ToStringBuilder builder = new ToStringBuilder(a, TO_STRING_STYLE);
        for (final Method m : a.annotationType().getDeclaredMethods()) {
            if (m.getParameterTypes().length > 0) {
                continue; //wtf?
            }
            try {
                builder.append(m.getName(), m.invoke(a));
            } catch (final RuntimeException ex) {
                throw ex;
            } catch (final Exception ex) {
                throw new RuntimeException(ex);
            }
        }
        return builder.build();
    }

    /**
     * <p>Checks if the specified type is permitted as an annotation member.</p>
     *
     * <p>The Java language specification only permits certain types to be used
     * in annotations. These include {@link String}, {@link Class}, primitive
     * types, {@link Annotation}, {@link Enum}, and single-dimensional arrays of
     * these types.</p>
     *
     * @param type the type to check, {@code null}
     * @return {@code true} if the type is a valid type to use in an annotation
     */
    public static boolean isValidAnnotationMemberType(Class<?> type) {
        if (type == null) {
            return false;
        }
        if (type.isArray()) {
            type = type.getComponentType();
        }
        return type.isPrimitive() || type.isEnum() || type.isAnnotation()
                || String.class.equals(type) || Class.class.equals(type);
    }

    //besides modularity, this has the advantage of autoboxing primitives:
    /**
     * Helper method for generating a hash code for a member of an annotation.
     *
     * @param name the name of the member
     * @param value the value of the member
     * @return a hash code for this member
     */
    private static int hashMember(String name, Object value) {
        final int part1 = name.hashCode() * 127;
        if (value.getClass().isArray()) {
            return part1 ^ arrayMemberHash(value.getClass().getComponentType(), value);
        }
        if (value instanceof Annotation) {
            return part1 ^ hashCode((Annotation) value);
        }
        return part1 ^ value.hashCode();
    }

    /**
     * Helper method for checking whether two objects of the given type are
     * equal. This method is used to compare the parameters of two annotation
     * instances.
     *
     * @param type the type of the objects to be compared
     * @param o1 the first object
     * @param o2 the second object
     * @return a flag whether these objects are equal
     */
    private static boolean memberEquals(Class<?> type, Object o1, Object o2) {
        if (o1 == o2) {
            return true;
        }
        if (o1 == null || o2 == null) {
            return false;
        }
        if (type.isArray()) {
            return arrayMemberEquals(type.getComponentType(), o1, o2);
        }
        if (type.isAnnotation()) {
            return equals((Annotation) o1, (Annotation) o2);
        }
        return o1.equals(o2);
    }

    /**
     * Helper method for comparing two objects of an array type.
     *
     * @param componentType the component type of the array
     * @param o1 the first object
     * @param o2 the second object
     * @return a flag whether these objects are equal
     */
    private static boolean arrayMemberEquals(Class<?> componentType, Object o1, Object o2) {
        if (componentType.isAnnotation()) {
            return annotationArrayMemberEquals((Annotation[]) o1, (Annotation[]) o2);
        }
        if (componentType.equals(Byte.TYPE)) {
            return Arrays.equals((byte[]) o1, (byte[]) o2);
        }
        if (componentType.equals(Short.TYPE)) {
            return Arrays.equals((short[]) o1, (short[]) o2);
        }
        if (componentType.equals(Integer.TYPE)) {
            return Arrays.equals((int[]) o1, (int[]) o2);
        }
        if (componentType.equals(Character.TYPE)) {
            return Arrays.equals((char[]) o1, (char[]) o2);
        }
        if (componentType.equals(Long.TYPE)) {
            return Arrays.equals((long[]) o1, (long[]) o2);
        }
        if (componentType.equals(Float.TYPE)) {
            return Arrays.equals((float[]) o1, (float[]) o2);
        }
        if (componentType.equals(Double.TYPE)) {
            return Arrays.equals((double[]) o1, (double[]) o2);
        }
        if (componentType.equals(Boolean.TYPE)) {
            return Arrays.equals((boolean[]) o1, (boolean[]) o2);
        }
        return Arrays.equals((Object[]) o1, (Object[]) o2);
    }

    /**
     * Helper method for comparing two arrays of annotations.
     *
     * @param a1 the first array
     * @param a2 the second array
     * @return a flag whether these arrays are equal
     */
    private static boolean annotationArrayMemberEquals(Annotation[] a1, Annotation[] a2) {
        if (a1.length != a2.length) {
            return false;
        }
        for (int i = 0; i < a1.length; i++) {
            if (!equals(a1[i], a2[i])) {
                return false;
            }
        }
        return true;
    }

    /**
     * Helper method for generating a hash code for an array.
     *
     * @param componentType the component type of the array
     * @param o the array
     * @return a hash code for the specified array
     */
    private static int arrayMemberHash(Class<?> componentType, Object o) {
        if (componentType.equals(Byte.TYPE)) {
            return Arrays.hashCode((byte[]) o);
        }
        if (componentType.equals(Short.TYPE)) {
            return Arrays.hashCode((short[]) o);
        }
        if (componentType.equals(Integer.TYPE)) {
            return Arrays.hashCode((int[]) o);
        }
        if (componentType.equals(Character.TYPE)) {
            return Arrays.hashCode((char[]) o);
        }
        if (componentType.equals(Long.TYPE)) {
            return Arrays.hashCode((long[]) o);
        }
        if (componentType.equals(Float.TYPE)) {
            return Arrays.hashCode((float[]) o);
        }
        if (componentType.equals(Double.TYPE)) {
            return Arrays.hashCode((double[]) o);
        }
        if (componentType.equals(Boolean.TYPE)) {
            return Arrays.hashCode((boolean[]) o);
        }
        return Arrays.hashCode((Object[]) o);
    }

    public static String formatDefaultElName(final String rawName) {
      if (rawName.isEmpty() || Character.isLowerCase(rawName.charAt(0)) || (rawName.length() > 1 && Character.isUpperCase(rawName.charAt(1)))) {
        return rawName;
      } else {
        final StringBuilder builder = new StringBuilder(rawName);
        builder.setCharAt(0, Character.toLowerCase(builder.charAt(0)));

        return builder.toString();
      }
    }

    public static Set<Class<?>> getQualifiersAsClasses() {
      final MetaDataScanner scanner = ScannerSingleton.getOrCreateInstance();
      final Set<Class<?>> typesAnnotatedWith = scanner.getTypesAnnotatedWith(Qualifier.class);
      typesAnnotatedWith.add(Named.class);
      return typesAnnotatedWith;
    }

    public static Set<MetaClass> getQualifiers() {
      final Set<Class<?>> qualifiersAsClasses = getQualifiersAsClasses();
      final Set<MetaClass> qualifiersAsMetaClasses = qualifiersAsClasses.stream().map(c -> MetaClassFactory.get(c)).collect(Collectors.toSet());

      if (qualifiersAsClasses.size() > qualifiersAsMetaClasses.size()) {
        throw new RuntimeException("Lost some qualifiers when converting from Class to MetaClass");
      }

      return qualifiersAsMetaClasses;
    }

    public static Collection<MetaMethod> getAnnotationAttributes(final MetaClass annoClass) {
      return filterAnnotationMethods(Arrays.stream(annoClass.getDeclaredMethods()),
              method -> !method.isAnnotationPresent(Nonbinding.class) && method.isPublic()
                      && !method.getName().equals("equals") && !method.getName().equals("hashCode"));
    }

    public static Collection<Method> getAnnotationAttributes(final Class<?> annoClass) {
      return filterAnnotationMethods(Arrays.stream(annoClass.getDeclaredMethods()),
              method -> !method.isAnnotationPresent(Nonbinding.class) && isPublic(method.getModifiers())
                      && !method.getName().equals("equals") && !method.getName().equals("hashCode"));
    }

    public static Collection<MetaMethod> getNonBindingAttributes(final MetaClass annoClass) {
      return filterAnnotationMethods(Arrays.stream(annoClass.getDeclaredMethods()),
              method -> method.isAnnotationPresent(Nonbinding.class) && method.isPublic()
                      && !method.getName().equals("equals") && !method.getName().equals("hashCode"));
    }

    public static Collection<Method> getNonBindingAttributes(final Class<?> annoClass) {
      return filterAnnotationMethods(Arrays.stream(annoClass.getDeclaredMethods()),
              method -> method.isAnnotationPresent(Nonbinding.class) && isPublic(method.getModifiers())
                      && !method.getName().equals("equals") && !method.getName().equals("hashCode"));
    }

    private static <T, M> Collection<M> filterAnnotationMethods(final Stream<M> methods, final Predicate<M> methodPredicate) {
      return methods.filter(methodPredicate).collect(Collectors.toList());
    }

    public static AnnotationPropertyAccessor createDynamicSerializer(final Class<? extends Annotation> annotationType) {
      final AnnotationPropertyAccessorBuilder builder = AnnotationPropertyAccessorBuilder.create();

      final Collection<Method> annoAttrs = CDIAnnotationUtils.getAnnotationAttributes(annotationType);
      for (final Method attr : annoAttrs) {
        builder.with(attr.getName(), anno -> {
          try {
            final String retVal;
            final Function<Object, String> toString = componentToString(
                  attr.getReturnType().isArray() ? attr.getReturnType().getComponentType() : attr.getReturnType());
            if (attr.getReturnType().isArray()) {
              final StringBuilder sb = new StringBuilder();
              final Object[] array = (Object[]) attr.invoke(anno);
              sb.append("[");
              for (final Object obj : array) {
                sb.append(toString.apply(obj)).append(",");
              }
              sb.replace(sb.length()-1, sb.length(), "]");
              retVal = sb.toString();
            }
            else {
              retVal = toString.apply(attr.invoke(anno));
            }
            return retVal;
          } catch (final Exception e) {
            throw new RuntimeException(String.format("Could not access '%s' property while serializing %s.", attr.getName(), anno.annotationType()), e);
          }
        });
      }

      return builder.build();
    }

    private static Function<Object, String> componentToString(final Class<?> returnType) {
      if (Class.class.equals(returnType)) {
        return o -> ((Class<?>) o).getName();
      }
      else {
        return o -> String.valueOf(o);
      }
    }
}
