package com.atlassian.bitbucket.mesh.util;

import com.google.common.collect.HashMultimap;
import com.google.common.collect.Multimap;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.TestExecutionListener;

import java.util.Iterator;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;

import static com.google.common.base.Preconditions.checkArgument;

/**
 * This class can hook into any method call within the {@code com.atlassian.bitbucket.mesh} package
 * and execute your own code before it runs. This is useful to get the application into a certain state when
 * some internal method is called.
 * <p>
 * Make use of this class like this in your test:
 * <pre>{@code
 * @Resource
 * private MethodCallHook methodCallHook;
 * }</pre>
 */
@Aspect
public class MethodCallHook implements TestExecutionListener {

    private static final Logger log = LoggerFactory.getLogger(MethodCallHook.class);

    private final Multimap<ExecutionKey, Execution> hooks;

    private volatile boolean hasHooks;

    public MethodCallHook() {
        hooks = HashMultimap.create();
        hasHooks = false;
    }

    @Override
    public void afterTestMethod(TestContext testContext) {
        hooks.clear();
        hasHooks = false;
    }

    /**
     * Hook into the execution of the {@code methodName} call on {@code clazz}.
     * <p>
     * It's essential to call {@link ExecutionHandler#proceed()} within the {@link Statement} for the execution
     * to proceed correctly.
     * <p>
     * <pre>{@code
     * methodCallHook.onExecution(SomeClass.class, "someMethod", execution -> {
     *      //Place code to run before the execution here
     *      Object returnValue = execution.proceed();
     *      //Place code to run after the execution here
     *      return returnValue;
     * });
     * }</pre>
     *
     * @param clazz      the class to hook into
     * @param methodName the method name to hook into
     * @param runnable   the runnable to execute when the hook is hit
     */
    public void onExecution(Class<?> clazz, String methodName, Statement runnable) {
        onExecution(new ExecutionKey(clazz, methodName), runnable, false);
    }

    /**
     * Hook into the execution of the {@code methodName} call on {@code clazz}. Once this hook has been called once,
     * it's removed again.
     * <p>
     * It's essential to call {@link ExecutionHandler#proceed()} within the {@link Statement} for the execution
     * to proceed correctly.
     * <p>
     * <pre>{@code
     * methodCallHook.onExecutionOnce(SomeClass.class, "someMethod", execution -> {
     *      //Place code to run before the execution here
     *      Object returnValue = execution.proceed();
     *      //Place code to run after the execution here
     *      return returnValue;
     * });
     * }</pre>
     */
    public void onExecutionOnce(Class<?> clazz, String methodName, Statement runnable) {
        onExecution(new ExecutionKey(clazz, methodName), runnable, true);
    }

    private static <E extends Throwable> ExecutionHandler wrapHandler(ProceedingJoinPoint joinPoint,
                                                                      Runnable remover,
                                                                      ThrowingSupplier<Object, E> proceed) {
        return new ExecutionHandler() {

            final Object[] args = joinPoint.getArgs();

            @Override
            public <T> T getArgument(int index) {
                checkArgument(index < args.length,
                        "index %s is out of range, only %s arguments for this method call",
                        index, args.length);
                @SuppressWarnings("unchecked")
                T arg = (T) args[index];
                return arg;
            }

            @Override
            public Object[] getArguments() {
                return args;
            }

            @SuppressWarnings("unchecked")
            @Override
            public <T> T proceed() throws Throwable {
                return (T) proceed.get();
            }

            @Override
            public void remove() {
                remover.run();
            }
        };
    }

    @Around("!within(is(FinalType)) && execution(* com.atlassian.bitbucket.mesh..*(..))")
    private Object beforeExecution(ProceedingJoinPoint joinPoint) throws Throwable {
        if (!hasHooks) {
            return joinPoint.proceed();
        }
        Signature signature = joinPoint.getSignature();

        Iterator<Execution> iterator =
                hooks.get(new ExecutionKey(signature.getDeclaringType(), signature.getName())).iterator();
        Statement stmt = ExecutionHandler::proceed;
        while (iterator.hasNext()) {
            Execution execution = iterator.next();
            Statement inner = stmt;
            AtomicBoolean removed = new AtomicBoolean();
            Runnable remover = () -> {
                if (removed.compareAndSet(false, true)) {
                    iterator.remove();
                }
            };
            stmt = handler -> inner.apply(wrapHandler(joinPoint, remover, () -> execution.runnable.apply(handler)));
            if (execution.once) {
                remover.run();
            }
        }
        return stmt.apply(wrapHandler(joinPoint, () -> { }, joinPoint::proceed));
    }

    private void onExecution(ExecutionKey clazz, Statement runnable, boolean once) {
        hooks.put(clazz, new Execution(runnable, once));
        hasHooks = true;
    }

    public interface ExecutionHandler {

        <T> T getArgument(int index);

        Object[] getArguments();

        <T> T proceed() throws Throwable;

        /**
         * Prevents this executor from being executed again.
         */
        void remove();
    }

    public interface Statement {

        Object apply(ExecutionHandler handler) throws Throwable;
    }

    interface ThrowingSupplier<T, E extends Throwable> {

        T get() throws E;
    }

    private static class Execution {

        final boolean once;
        final Statement runnable;

        public Execution(Statement runnable, boolean once) {
            this.runnable = runnable;
            this.once = once;
        }
    }

    private static class ExecutionKey {

        final Class<?> clazz;
        final String method;

        public ExecutionKey(Class<?> clazz, String method) {
            this.clazz = clazz;
            this.method = method;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }
            ExecutionKey that = (ExecutionKey) o;
            return Objects.equals(clazz, that.clazz) && Objects.equals(method, that.method);
        }

        @Override
        public int hashCode() {
            return Objects.hash(clazz, method);
        }
    }
}
