/*
 * Copyright 2010 the original author or authors.
 *
 * Licensed 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.gradle.initialization;

import org.apache.commons.lang.StringUtils;
import org.gradle.api.LocationAwareException;
import org.gradle.api.internal.ClassGenerator;
import org.gradle.api.internal.Contextual;
import org.gradle.groovy.scripts.ScriptSource;
import org.gradle.util.ReflectionUtil;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * A {@link ClassGenerator} which mixes {@link org.gradle.api.LocationAwareException} into the supplied exception
 * types. Uses {@link ExceptionHelper} to do the work.
 */
public class ExceptionDecoratingClassGenerator implements ClassGenerator {
    private static final Map<Class<?>, Class<?>> GENERATED_CLASSES = new HashMap<Class<?>, Class<?>>();

    public <T> T newInstance(Class<T> type, Object... parameters) {
        Throwable throwable = ReflectionUtil.newInstance(generate(type), parameters);
        throwable.setStackTrace(((Throwable) parameters[0]).getStackTrace());
        return type.cast(throwable);
    }

    public <T> Class<? extends T> generate(Class<T> type) {
        Class generated = GENERATED_CLASSES.get(type);
        if (generated == null) {
            generated = doGenerate(type);
            GENERATED_CLASSES.put(type, generated);
        }
        return generated;
    }

    private <T> Class<? extends T> doGenerate(Class<T> type) {
        ClassWriter visitor = new ClassWriter(ClassWriter.COMPUTE_MAXS);
        String typeName = StringUtils.substringBeforeLast(type.getName(), ".") + ".LocationAware" + type.getSimpleName();
        Type generatedType = Type.getType("L" + typeName.replaceAll("\\.", "/") + ";");
        Type superclassType = Type.getType(type);

        visitor.visit(Opcodes.V1_5, Opcodes.ACC_PUBLIC, generatedType.getInternalName(), null,
                superclassType.getInternalName(), new String[]{Type.getType(LocationAwareException.class).getInternalName()});

        Type helperType = Type.getType(ExceptionHelper.class);
        Type throwableType = Type.getType(Throwable.class);
        Type scriptSourceType = Type.getType(ScriptSource.class);
        Type integerType = Type.getType(Integer.class);

        // GENERATE private ExceptionHelper helper;
        visitor.visitField(Opcodes.ACC_PRIVATE, "helper", helperType.getDescriptor(), null, null);

        // GENERATE <init>(<type> target, ScriptSource source, Integer lineNumber)

        String methodDescriptor = Type.getMethodDescriptor(Type.VOID_TYPE,
                new Type[]{superclassType, scriptSourceType, integerType});
        MethodVisitor methodVisitor = visitor.visitMethod(Opcodes.ACC_PUBLIC, "<init>", methodDescriptor, null,
                new String[0]);
        methodVisitor.visitCode();

        boolean noArgsConstructor;
        try {
            type.getConstructor(type);
            noArgsConstructor = false;
        } catch (NoSuchMethodException e) {
            try {
                type.getConstructor();
                noArgsConstructor = true;
            } catch (NoSuchMethodException e1) {
                throw new IllegalArgumentException(String.format(
                        "Cannot create subtype for exception '%s'. It needs a zero-args or copy constructor.",
                        type.getName()));
            }
        }

        if (noArgsConstructor) {
            // GENERATE super()
            methodVisitor.visitVarInsn(Opcodes.ALOAD, 0);
            methodVisitor.visitMethodInsn(Opcodes.INVOKESPECIAL, superclassType.getInternalName(), "<init>",
                    Type.getMethodDescriptor(Type.VOID_TYPE, new Type[0]));
            // END super()
        } else {
            // GENERATE super(target)
            methodVisitor.visitVarInsn(Opcodes.ALOAD, 0);
            methodVisitor.visitVarInsn(Opcodes.ALOAD, 1);
            methodVisitor.visitMethodInsn(Opcodes.INVOKESPECIAL, superclassType.getInternalName(), "<init>",
                    Type.getMethodDescriptor(Type.VOID_TYPE, new Type[]{superclassType}));
            // END super(target)
        }

        // GENERATE helper = new ExceptionHelper(this, target, source, lineNumber)
        methodVisitor.visitVarInsn(Opcodes.ALOAD, 0);

        methodVisitor.visitTypeInsn(Opcodes.NEW, helperType.getInternalName());
        methodVisitor.visitInsn(Opcodes.DUP);
        methodVisitor.visitVarInsn(Opcodes.ALOAD, 0);
        methodVisitor.visitVarInsn(Opcodes.ALOAD, 1);
        methodVisitor.visitVarInsn(Opcodes.ALOAD, 2);
        methodVisitor.visitVarInsn(Opcodes.ALOAD, 3);
        methodVisitor.visitMethodInsn(Opcodes.INVOKESPECIAL, helperType.getInternalName(), "<init>",
                Type.getMethodDescriptor(Type.VOID_TYPE, new Type[]{throwableType, throwableType, scriptSourceType, integerType}));

        methodVisitor.visitFieldInsn(Opcodes.PUTFIELD, generatedType.getInternalName(), "helper",
                helperType.getDescriptor());

        // END helper = new ExceptionHelper(target)

        methodVisitor.visitInsn(Opcodes.RETURN);
        methodVisitor.visitMaxs(0, 0);
        methodVisitor.visitEnd();

        // END <init>(<type> target, ScriptSource source, Integer lineNumber)

        for (Method method : ExceptionHelper.class.getDeclaredMethods()) {
            // GENERATE public <type> <method>() { return helper.<method>(); }
            methodDescriptor = Type.getMethodDescriptor(Type.getType(method.getReturnType()), new Type[0]);
            methodVisitor = visitor.visitMethod(Opcodes.ACC_PUBLIC, method.getName(), methodDescriptor, null,
                    new String[0]);
            methodVisitor.visitCode();

            methodVisitor.visitVarInsn(Opcodes.ALOAD, 0);
            methodVisitor.visitFieldInsn(Opcodes.GETFIELD, generatedType.getInternalName(), "helper",
                    helperType.getDescriptor());
            methodVisitor.visitMethodInsn(Opcodes.INVOKEVIRTUAL, helperType.getInternalName(), method.getName(),
                    methodDescriptor);

            methodVisitor.visitInsn(Opcodes.ARETURN);
            methodVisitor.visitMaxs(0, 0);
            methodVisitor.visitEnd();
            // END public <type> <method>() { return helper.<method>(); }
        }

        visitor.visitEnd();

        byte[] bytecode = visitor.toByteArray();
        return (Class<T>) ReflectionUtil.invoke(type.getClassLoader(), "defineClass", new Object[]{
                typeName, bytecode, 0, bytecode.length
        });
    }

    public static class ExceptionHelper {
        private final Throwable target;
        private final ScriptSource source;
        private final Integer lineNumber;

        public ExceptionHelper(Throwable owner, Throwable target, ScriptSource source, Integer lineNumber) {
            if (owner.getCause() == null) {
                owner.initCause(target.getCause());
            }
            this.target = target;
            this.source = source;
            this.lineNumber = lineNumber;
        }

        public String getOriginalMessage() {
            return target.getMessage();
        }

        public Throwable getOriginalException() {
            return target;
        }

        public String getLocation() {
            if (source == null) {
                return null;
            }
            String sourceMsg = StringUtils.capitalize(source.getDisplayName());
            if (lineNumber == null) {
                return sourceMsg;
            }
            return String.format("%s line: %d", sourceMsg, lineNumber);
        }

        public String getMessage() {
            String location = getLocation();
            String message = target.getMessage();
            if (location == null && message == null) {
                return null;
            }
            if (location == null) {
                return message;
            }
            if (message == null) {
                return location;
            }
            return String.format("%s%n%s", location, message);
        }

        public ScriptSource getScriptSource() {
            return source;
        }

        public Integer getLineNumber() {
            return lineNumber;
        }

        public List<Throwable> getReportableCauses() {
            ArrayList<Throwable> causes = new ArrayList<Throwable>();
            for (Throwable t = target.getCause(); t != null; t = t.getCause()) {
                causes.add(t);
                if (t.getClass().getAnnotation(Contextual.class) == null) {
                    break;
                }
            }
            return causes;
        }

    }
}
