/*
 * #%L
 * Alfresco Repository
 * %%
 * Copyright (C) 2005 - 2016 Alfresco Software Limited
 * %%
 * This file is part of the Alfresco software. 
 * If the software was purchased under a paid Alfresco license, the terms of 
 * the paid license agreement will prevail.  Otherwise, the software is 
 * provided under the following open source license terms:
 * 
 * Alfresco is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * Alfresco is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public License
 * along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
 * #L%
 */

package org.alfresco.traitextender;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.UndeclaredThrowableException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Stack;

import java.util.concurrent.ConcurrentHashMap;
import org.alfresco.util.ParameterCheck;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;

/**
 * Static utility used for aspectJ extension consistency , routing and for
 * maintaining thread-local extension-bypass context stack.<br>
 * AspectJ extension routing distinguishes between the following contexts in
 * which an extended method (i.e. a method with an {@link Extend} annotation)
 * can be called:
 * <ol>
 * <li>Extend context<br>
 * when an extended method is called and the extension overrides the method call
 * </li>
 * <li>Local proceed context<br>
 * when an extension method needs to execute the original method that it has
 * overridden.</li>
 * <li>Extension bypass context<br>
 * when a call to an extended method needs to be completed with the extensions
 * disabled for that call</li>
 * </ol>
 * <br>
 * The {@link AJExtender} can check {@link ExtensionPoint} definitions for
 * consistency by compiling extensible classes. The compilation process fails if
 * there are inconsistencies between {@link ExtensionPoint}s and {@link Extend}
 * annotated methods (egg. an annotated method can not be mapped to a method in
 * the indicated extension by signature matching).
 * 
 * @author Bogdan Horje
 */
public class AJExtender
{
    private static final Object[] SAFE_NULL_ARGS = new Object[0];

    private static Log logger = LogFactory.getLog(AJExtender.class);

    private static ConcurrentHashMap.KeySetView<ExtensionRoute, Boolean> oneTimeLogSet = null;

    /**
     *  
     */
    private static final ThreadLocal<Stack<Boolean>> ajPointsLocalEnabled = new ThreadLocal<Stack<Boolean>>()
    {
        protected Stack<Boolean> initialValue()
        {
            Stack<Boolean> enablementStack = new Stack<Boolean>();
            enablementStack.push(true);
            return enablementStack;
        };
    };

    /**
     * @author Bogdan Horje
     */
    static class ProceedingContext
    {
        final Extend extend;

        final ProceedingJoinPoint proceedingJoinPoint;

        ProceedingContext(Extend extend, ProceedingJoinPoint proceedingJoinPoint)
        {
            super();
            this.extend = extend;
            this.proceedingJoinPoint = proceedingJoinPoint;
        }

    }

    private static final ThreadLocal<Stack<ProceedingContext>> ajLocalProceedingJoinPoints = new ThreadLocal<Stack<ProceedingContext>>()
    {
        protected java.util.Stack<ProceedingContext> initialValue()
        {
            return new Stack<>();
        };
    };

    /**
     * Implementors are aspectJ extension ignoring closures. When executing
     * {@link ExtensionBypass}es using {@link AJExtender#run(ExtensionBypass)} or
     * {@link AJExtender#run(ExtensionBypass, Class[])} the {@link #run()}
     * method code will ignore extension overrides.
     *
     * @author Bogdan Horje
     */
    public static interface ExtensionBypass<R>
    {
        R run() throws Throwable;
    }

    /**
     * Thrown-exception or stored error resulted compiling inconsistencies found
     * during aspectJ extensible classes compilation.
     *
     * @see {@link AJExtender#compile(Class)}
     * @author Bogdan Horje
     */
    interface AJExtensibleCompilingError
    {
        String getShortMessage();
    }

    /**
     * Thrown or stored on compiling inconsistencies found during aspectJ
     * extensible classes compilation.
     *
     * @see {@link AJExtender#compile(Class)}
     * @author Bogdan Horje
     */
    static class AJExtensibleCompilingException extends Exception implements AJExtensibleCompilingError
    {

        /**
         * 
         */
        private static final long serialVersionUID = 1L;

        AJExtensibleCompilingException()
        {
            super();
        }

        AJExtensibleCompilingException(String message, Throwable cause, boolean enableSuppression,
                    boolean writableStackTrace)
        {
            super(message,
                  cause,
                  enableSuppression,
                  writableStackTrace);
        }

        AJExtensibleCompilingException(String message, Throwable cause)
        {
            super(message,
                  cause);
        }

        AJExtensibleCompilingException(String message)
        {
            super(message);
        }

        AJExtensibleCompilingException(Throwable cause)
        {
            super(cause);
        }

        @Override
        public String getShortMessage()
        {
            return getMessage();
        }

    }

    /**
     * Signals the existence of extension methods that are not matched with
     * same-signature methods through {@link Extend} annotations within
     * {@link Extensible}.
     *
     * @author Bogdan Horje
     */
    static class AJDanglingExtensionError implements AJExtensibleCompilingError
    {
        private Method danglingMethod;

        private Extend extendDeclaration;

        AJDanglingExtensionError(Method danglingMethod, Extend extendDeclaration)
        {
            super();
            this.danglingMethod = danglingMethod;
            this.extendDeclaration = extendDeclaration;
        }

        @Override
        public String getShortMessage()
        {
            return "Dangling extension method " + danglingMethod + " " + extendDeclaration;
        }
    }

    /**
     * An {@link Extensible} sub class compilation result containing all
     * {@link Extend} mapped routes, not routed or dangling methods within the
     * give extensible class and/or possible compilation errors.
     *
     * @author Bogdan Horje
     */
    static class CompiledExtensible
    {
        private Class<? extends Extensible> extensible;

        private Map<Method, ExtensionRoute> routedMethods = new HashMap<>();

        private Map<Method, ExtensionRoute> notRoutedMethods = new HashMap<>();

        private List<AJExtensibleCompilingError> errors = new LinkedList<>();

        CompiledExtensible(Class<? extends Extensible> extensible)
        {
            super();
            this.extensible = extensible;
        }

        Class<? extends Extensible> getExtensible()
        {
            return this.extensible;
        }

        void add(AJExtensibleCompilingError error)
        {
            this.errors.add(error);
        }

        boolean hasErrors()
        {
            return !errors.isEmpty();
        }

        String getErrorsString()
        {
            StringBuilder builder = new StringBuilder();

            for (AJExtensibleCompilingError error : errors)
            {
                builder.append(error.getShortMessage());
                builder.append("\n");
            }

            return builder.toString();
        }

        List<AJExtensibleCompilingError> getErrors()
        {
            return this.errors;
        }

        void add(ExtensionRoute route)
        {
            if (route.extensionMethod == null)
            {
                notRoutedMethods.remove(route.extendedMethod);
                routedMethods.put(route.extendedMethod,
                                  route);
            }
            else if (!routedMethods.containsKey(route.extendedMethod))
            {
                routedMethods.put(route.extendedMethod,
                                  route);
            }
        }

        Collection<ExtensionRoute> getAllNotRouted()
        {
            return notRoutedMethods.values();
        }

        int getExtendedMethodCount()
        {
            return routedMethods.size() + notRoutedMethods.size();
        }

        String getInfo()
        {
            return extensible.getName() + "{ " + routedMethods.size() + " routed methods; " + notRoutedMethods.size()
                        + " not routed methods;" + errors.size() + " errors}";
        }
    }

    /**
     * Encapsulates extended-method to extension-method mapping information.
     *
     * @author Bogdan Horje
     */
    static class ExtensionRoute
    {
        final Extend extendAnnotation;

        final Method extendedMethod;

        final Method extensionMethod;

        ExtensionRoute(Extend extendAnnotation, Method traitMethod)
        {
            this(extendAnnotation,
                 traitMethod,
                 null);
        }

        ExtensionRoute(Extend extendAnnotation, Method extendedMethod, Method extensionMethod)
        {
            super();
            ParameterCheck.mandatory("extendAnnotation",
                                     extendAnnotation);
            ParameterCheck.mandatory("traitMethod",
                                     extendedMethod);

            this.extendAnnotation = extendAnnotation;
            this.extendedMethod = extendedMethod;
            this.extensionMethod = extensionMethod;
        }

        @Override
        public boolean equals(Object obj)
        {
            if (obj instanceof ExtensionRoute)
            {
                ExtensionRoute route = (ExtensionRoute) obj;
                return extendAnnotation.traitAPI().equals(route.extendAnnotation.traitAPI())
                            && extendAnnotation.extensionAPI().equals(route.extendAnnotation.extensionAPI())
                            && extendedMethod.equals(route.extendedMethod)
                            && ((extensionMethod == null && route.extensionMethod == null) || (extensionMethod != null && extensionMethod
                                        .equals(route.extensionMethod)));
            }
            else
            {
                return false;
            }
        }

        @Override
        public String toString()
        {
            String extensionString = "NOT ROUTED";

            if (extensionMethod != null)
            {
                Class<?> exDeclClass = extendedMethod.getDeclaringClass();
                extensionString = extensionMethod.toGenericString() + "#" + exDeclClass;
            }

            return extendAnnotation.toString() + "\t\n[" + extendedMethod.toGenericString() + " -> " + extensionString
                        + "]";
        }

        @Override
        public int hashCode()
        {
            return extendAnnotation.hashCode();
        }
    }

    /**
     * @return <code>true</code> if aspectJ routed extensions are enabled on the
     *         call stack of the current thread
     */
    static boolean areAJPointsEnabled()
    {
        return ajPointsLocalEnabled.get().peek();
    }

    /**
     * Pushes a new enabled state of the aspectJ routed extensions on the
     * current thread execution stack.
     */
    static void enableAJPoints()
    {
        ajPointsLocalEnabled.get().push(true);
    }

    /**
     * Pops the current aspectJ routed extensions enablement state from the
     * current thread execution stack.
     */
    static void revertAJPoints()
    {
        ajPointsLocalEnabled.get().pop();
    }

    /**
     * Exception throwing extension-bypass closure runner method. <br>
     * Sets up adequate call contexts to avoid exception calling and than
     * delegates to the given closure.
     * 
     * @param closure
     * @return
     * @throws Throwable
     */
    static <R> R throwableRun(AJExtender.ExtensionBypass<R> closure) throws Throwable
    {
        try
        {
            AJExtender.ajPointsLocalEnabled.get().push(false);
            return closure.run();

        }
        finally
        {
            AJExtender.ajPointsLocalEnabled.get().pop();
        }
    }

    /**
     * Extension-bypass closure runner method. <br>
     * Sets up adequate call contexts to avoid exception calling and than
     * delegates to the given closure.<br>
     * Only the given exTypes exceptions will be passed on to the calling
     * context, all others will be wrapped as
     * {@link UndeclaredThrowableException}s.
     * 
     * @param closure
     * @param exTypes
     * @return
     * @throws Throwable
     */
    public static <R> R run(AJExtender.ExtensionBypass<R> closure, Class<?>[] exTypes) throws Throwable
    {
        try
        {
            return throwableRun(closure);
        }
        catch (Error | RuntimeException error)
        {
            throw error;
        }
        catch (Throwable error)
        {
            throw asCheckThrowable(error,
                                   exTypes);
        }
    }

    static Throwable asCheckThrowable(Throwable error, Class<?>... checkedThrowableTypes)
    {
        Class<? extends Throwable> errorClass = error.getClass();
        for (int i = 0; i < checkedThrowableTypes.length; i++)
        {
            if (errorClass.equals(checkedThrowableTypes[i]))
            {
                return error;
            }
        }
        return new UndeclaredThrowableException(error);
    }

    /**
     * Extension-bypass closure runner method. <br>
     * Sets up adequate call contexts to avoid exception calling and than
     * delegates to the given closure.
     * 
     * @param closure
     * @return
     */
    public static <R> R run(AJExtender.ExtensionBypass<R> closure)
    {
        try
        {
            return throwableRun(closure);
        }
        catch (Error | RuntimeException error)
        {
            throw error;
        }
        catch (Throwable error)
        {
            throw new UndeclaredThrowableException(error);
        }
    }

    /**
     * Logs each method routing path once per session.
     * 
     * @param logger
     * @param route
     */
    static void oneTimeLiveLog(Log logger, ExtensionRoute route)
    {
        synchronized (AJExtender.class)
        {
            if (oneTimeLogSet == null)
            {
                oneTimeLogSet = ConcurrentHashMap.newKeySet();
            }
        }

        synchronized (oneTimeLogSet)
        {
            if (oneTimeLogSet.contains(route))
            {
                return;
            }
            else
            {
                logger.debug(route.toString());
                oneTimeLogSet.add(route);
            }
        }
    }

    /**
     * @param extensible
     * @return a compilation result containing all {@link Extend} mapped routes,
     *         not routed or dangling methods within the give extensible class.
     * @throws AJExtensibleCompilingException
     */
    static CompiledExtensible compile(Class<? extends Extensible> extensible) throws AJExtensibleCompilingException
    {
        logger.info("Compiling extensible " + extensible);

        CompiledExtensible compiledExtensible = new CompiledExtensible(extensible);

        List<Method> methods = new ArrayList<>();
        Class<?> extendDeclaring = extensible;
        while (extendDeclaring != null)
        {
            Method[] declaredMethods = extendDeclaring.getDeclaredMethods();
            methods.addAll(Arrays.asList(declaredMethods));
            extendDeclaring = extendDeclaring.getSuperclass();
        }
        Set<Extend> extendDeclarations = new HashSet<>();
        Set<Method> routedExtensionMethods = new HashSet<>();
        for (Method method : methods)
        {

            Extend extend = method.getAnnotation(Extend.class);
            if (extend != null)
            {
                try
                {
                    extendDeclarations.add(extend);
                    Class<?> extensionAPI = extend.extensionAPI();
                    Method extensionMethod = extensionAPI.getMethod(method.getName(),
                                                                    method.getParameterTypes());
                    compiledExtensible.add(new ExtensionRoute(extend,
                                                              method,
                                                              extensionMethod));
                    routedExtensionMethods.add(extensionMethod);
                }
                catch (NoSuchMethodException error)
                {
                    AJExtensibleCompilingException ajCompilingError = new AJExtensibleCompilingException("No route for "
                                                                                                                     + method.toGenericString()
                                                                                                                     + " @"
                                                                                                                     + extend,
                                                                                                         error);
                    compiledExtensible.add(ajCompilingError);
                }
                catch (SecurityException error)
                {
                    AJExtensibleCompilingException ajCompilingError = new AJExtensibleCompilingException("Access denined to route for "
                                                                                                                     + method.toGenericString()
                                                                                                                     + " @"
                                                                                                                     + extend,
                                                                                                         error);
                    compiledExtensible.add(ajCompilingError);
                }
            }

        }

        final Set<Method> allObjectMethods = new HashSet<>(Arrays.asList(Object.class.getMethods()));

        for (Extend extend : extendDeclarations)
        {
            Class<?> extension = extend.extensionAPI();

            Set<Method> allExtensionMethods = new HashSet<>(Arrays.asList(extension.getMethods()));
            allExtensionMethods.removeAll(allObjectMethods);
            allExtensionMethods.removeAll(routedExtensionMethods);
            if (!allExtensionMethods.isEmpty())
            {
                for (Method method : allExtensionMethods)
                {
                    compiledExtensible.add(new AJDanglingExtensionError(method,
                                                                        extend));
                }
            }
        }

        logger.info(compiledExtensible.getInfo());

        return compiledExtensible;
    }

    /**
     * Around advice helper that matches the advised method with its
     * corresponding extension method, sets up aspectJ call contexts (egg. the
     * local-proceed context) and delegates to the extension method.
     * 
     * @param thisJoinPoint
     * @param extensible
     * @param extendAnnotation
     * @param extension
     * @return the result of the extended method
     */
    static Object extendAroundAdvice(JoinPoint thisJoinPoint, Extensible extensible, Extend extendAnnotation,
                Object extension)
    {

        MethodSignature ms = (MethodSignature) thisJoinPoint.getSignature();
        Method method = ms.getMethod();
        try
        {
            ajLocalProceedingJoinPoints.get().push(new ProceedingContext(extendAnnotation,
                                                                         (ProceedingJoinPoint) thisJoinPoint));

            Method extensionMethod = extension.getClass().getMethod(method.getName(),
                                                                    method.getParameterTypes());
            if (logger.isDebugEnabled())
            {
                oneTimeLiveLog(AJExtender.logger,
                               new ExtensionRoute(extendAnnotation,
                                                  method,
                                                  extensionMethod));
            }

            return extensionMethod.invoke(extension,
                                          thisJoinPoint.getArgs());
        }
        catch (IllegalAccessException error)
        {
            throw new InvalidExtension("Ivalid extension : " + error.getMessage(),
                                       error);
        }
        catch (IllegalArgumentException error)
        {
            throw new InvalidExtension("Ivalid extension : " + error.getMessage(),
                                       error);
        }
        catch (InvocationTargetException error)
        {
            Throwable targetException = error.getTargetException();
            if (targetException instanceof RuntimeException)
            {
                throw (RuntimeException) targetException;
            }
            else
            {
                throw new ExtensionTargetException(targetException);
            }
        }
        catch (NoSuchMethodException error)
        {
            throw new InvalidExtension("Ivalid extension : " + error.getMessage(),
                                       error);
        }
        catch (SecurityException error)
        {
            throw new InvalidExtension("Ivalid extension : " + error.getMessage(),
                                       error);
        }
        finally
        {
            ajLocalProceedingJoinPoints.get().pop();
        }

    }

    /**
     * @param method
     * @return <code>true</code> if the given method has the same signature as
     *         the currently aspectJ extension-overridden method
     */
    static boolean isLocalProceeder(Method method)
    {
        if (!ajLocalProceedingJoinPoints.get().isEmpty())
        {
            ProceedingContext proceedingCotext = ajLocalProceedingJoinPoints.get().peek();
            MethodSignature ms = (MethodSignature) proceedingCotext.proceedingJoinPoint.getSignature();
            Method jpMethod = ms.getMethod();
            return jpMethod.getName().endsWith(method.getName()) && Arrays.equals(jpMethod.getParameterTypes(),
                                                                                  method.getParameterTypes());
        }
        else
        {
            return false;
        }
    }

    /**
     * Calls the currently overridden method in local-proceed context - proceeds
     * with the aspectJ join point saved on the current call stack.<br>
     * 
     * @param args
     * @throws Throwable
     */
    static Object localProceed(Object[] args) throws Throwable
    {
        ProceedingContext proceedingCotext = ajLocalProceedingJoinPoints.get().peek();
        Object[] safeArgs = args == null ? SAFE_NULL_ARGS : args;
        return proceedingCotext.proceedingJoinPoint.proceed(safeArgs);
    }
}
