package com.atlassian.bitbucket.mesh.util;

import com.atlassian.bitbucket.mesh.config.ApplicationConfig;
import com.atlassian.bitbucket.mesh.config.DefaultApplicationConfig;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.aop.framework.AopConfigException;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.TestExecutionListener;

import javax.annotation.Nonnull;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static java.lang.Character.toLowerCase;
import static java.lang.reflect.Modifier.isFinal;
import static java.lang.reflect.Modifier.isNative;

/**
 * This class can override any values returned from {@link ApplicationConfig}, specifically its implementation
 * {@link DefaultApplicationConfig}.
 * <p>
 * Make use of this class like this in your test:
 * <pre>{@code
 * @Resource
 * private ApplicationConfigOverrider config;
 * }</pre>
 */
@Aspect
public class ApplicationConfigOverrider implements TestExecutionListener {

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

    private final Map<String, Object> overrides;

    public ApplicationConfigOverrider() {
        overrides = new HashMap<>();
    }

    /**
     * Overrides the value of the config property with the provided value. Note that it is up to the caller to provide
     * an object of the correct type, otherwise a warning will be logged.
     *
     * @param configProperty the case-insensitive name of the config property that follows bean naming conventins, e.g.
     *                       {@code git.gc.delay}
     * @param value          the value to return
     */
    public void addOverride(String configProperty, Object value) {
        String string = configProperty.toLowerCase();
        Matcher matcher = Pattern.compile("-([a-zA-Z])").matcher(string);
        StringBuffer buf = new StringBuffer();
        while (matcher.find()) {
            matcher.appendReplacement(buf, matcher.group(1).toUpperCase());
        }
        matcher.appendTail(buf);
        overrides.put(buf.toString(), value);
    }

    @Override
    public void afterTestMethod(TestContext testContext) {
        overrides.clear();
    }

    private static boolean isSuitableForProxying(Object proceed) {
        if (proceed != null) {
            Class<?> clazz = proceed.getClass();
            return !isFinal(clazz.getModifiers()) &&
                    !isNative(clazz.getModifiers()) &&
                    clazz.getName().startsWith("com.atlassian.bitbucket.mesh");
        }
        return false;
    }

    private static String toPropertyName(String name) {
        return toLowerCase(name.charAt(3)) + name.substring(4);
    }

    @Around("!within(is(FinalType)) && execution(* com.atlassian.bitbucket.mesh.config.DefaultApplicationConfig.get*(..))")
    private Object beforeExecution(ProceedingJoinPoint joinPoint) throws Throwable {
        Object proceed = joinPoint.proceed();

        return getProxy(proceed, toPropertyName(joinPoint.getSignature().getName()));
    }

    private Object getProxy(Object proceed, String path) {
        if (isSuitableForProxying(proceed)) {
            ProxyFactory proxyFactory = new ProxyFactory(proceed);
            proxyFactory.setProxyTargetClass(true);
            proxyFactory.addAdvice(new OverridingMethodInterceptor(path));
            try {
                return proceed.getClass().cast(proxyFactory.getProxy());
            } catch (AopConfigException e) {
                return proceed;
            }
        }
        return proceed;
    }

    private class OverridingMethodInterceptor implements MethodInterceptor {

        private final String path;

        public OverridingMethodInterceptor(String path) {
            this.path = path;
        }

        @Override
        public Object invoke(@Nonnull MethodInvocation invocation) throws Throwable {
            if (invocation.getMethod().getName().startsWith("get")) {
                String newPath = path + "." + toPropertyName(invocation.getMethod().getName());
                Object override = overrides.get(newPath);
                try {
                    if (override != null) {
                        return invocation.getMethod().getReturnType().cast(override);
                    }
                } catch (ClassCastException e) {
                    log.warn("Failed to override '{}': {}. Returning original value.", newPath, e.getMessage());
                }
                return getProxy(invocation.proceed(), newPath);
            }
            return invocation.proceed();
        }
    }
}
