/*
 * Copyright 2011 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.powermock.core;

import javassist.CannotCompileException;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtConstructor;
import javassist.CtField;
import javassist.CtMethod;
import javassist.CtNewConstructor;
import javassist.CtNewMethod;
import javassist.Modifier;
import javassist.NotFoundException;

import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * This class takes care of creating a replica of a class. The class structure
 * is copied to the new class. This is useful in situations where you want to
 * create a mock for a class but it's not possible because of some restrictions
 * (such as the class being loaded by the bootstrap class-loader).
 */
public class ClassReplicaCreator {

    private static final String POWERMOCK_INSTANCE_DELEGATOR_FIELD_NAME = "powerMockInstanceDelegatorField";
    // Used to make each new replica class of a specific type unique.
    private static AtomicInteger counter = new AtomicInteger(0);

    public <T> Class<T> createClassReplica(Class<T> clazz) {
        if (clazz == null) {
            throw new IllegalArgumentException("clazz cannot be null");
        }
        ClassPool classpool = ClassPool.getDefault();
        final String originalClassName = clazz.getName();
        CtClass originalClassAsCtClass;
        final CtClass newClass = classpool.makeClass(generateReplicaClassName(clazz));
        try {
            originalClassAsCtClass = classpool.get(originalClassName);
            CtMethod[] declaredMethods = originalClassAsCtClass.getDeclaredMethods();
            for (CtMethod ctMethod : declaredMethods) {
                final String code = getReplicaMethodDelegationCode(clazz, ctMethod, null);
                CtNewMethod.make(ctMethod.getReturnType(), ctMethod.getName(), ctMethod.getParameterTypes(), ctMethod.getExceptionTypes(),
                        code, newClass);
            }

            return (Class<T>) newClass.toClass(this.getClass().getClassLoader(), this.getClass().getProtectionDomain());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Create a class that is a replica of type {@code T}. To allow for
     * partial mocking all calls to non-mocked methods will be delegated to the
     * {@code delegator}.
     *
     * @param <T>       The type of the replica class to be created.
     * @param delegator The delegator object that will be invoked to allow for partial
     *                  mocking.
     * @return A replica class that can be used to duck-type an instance.
     */
    @SuppressWarnings("unchecked")
    public <T> Class<T> createInstanceReplica(T delegator) {
        if (delegator == null) {
            throw new IllegalArgumentException("delegator cannot be null");
        }
        final Class<T> clazz = (Class<T>) delegator.getClass();
        ClassPool classpool = ClassPool.getDefault();
        final String originalClassName = clazz.getName();
        CtClass originalClassAsCtClass;
        final CtClass newClass = classpool.makeClass(generateReplicaClassName(clazz));
        try {
            originalClassAsCtClass = classpool.get(originalClassName);

            copyFields(originalClassAsCtClass, newClass);
            addDelegatorField(delegator, newClass);

            CtMethod[] declaredMethods = originalClassAsCtClass.getDeclaredMethods();
            for (CtMethod ctMethod : declaredMethods) {
                @SuppressWarnings("unused")
                final String code = getReplicaMethodDelegationCode(delegator.getClass(), ctMethod, POWERMOCK_INSTANCE_DELEGATOR_FIELD_NAME);
                CtMethod make2 = CtNewMethod.copy(ctMethod, newClass, null);
                newClass.addMethod(make2);
            }

            CtConstructor[] declaredConstructors = originalClassAsCtClass.getDeclaredConstructors();
            for (CtConstructor ctConstructor : declaredConstructors) {
                CtConstructor copy = CtNewConstructor.copy(ctConstructor, newClass, null);
                newClass.addConstructor(copy);
            }
            return (Class<T>) newClass.toClass(this.getClass().getClassLoader(), this.getClass().getProtectionDomain());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Add a field to the replica class that holds the instance delegator. I.e.
     * if we're creating a instance replica of {@code java.lang.Long} this
     * methods adds a new field of type {@code delegator.getClass()} to the
     * replica class.
     */
    private <T> void addDelegatorField(T delegator, final CtClass replicaClass) throws CannotCompileException {
        CtField f = CtField.make(String.format("private %s %s = null;", delegator.getClass().getName(),
                POWERMOCK_INSTANCE_DELEGATOR_FIELD_NAME), replicaClass);
        replicaClass.addField(f);
    }

    private <T> String generateReplicaClassName(final Class<T> clazz) {
        return "replica." + clazz.getName() + "$$PowerMock" + counter.getAndIncrement();
    }

    private void copyFields(CtClass originalClassAsCtClass, final CtClass newClass) throws CannotCompileException, NotFoundException {
        CtField[] declaredFields = originalClassAsCtClass.getDeclaredFields();
        CtField[] undeclaredFields = originalClassAsCtClass.getFields();
        Set<CtField> allFields = new HashSet<CtField>();
        Collections.addAll(allFields, declaredFields);
        Collections.addAll(allFields, undeclaredFields);

        for (CtField ctField : allFields) {
            CtField f = new CtField(ctField.getType(), ctField.getName(), newClass);
            newClass.addField(f);
        }
    }

    /*
     * Invokes a instance method of the original instance. This enables partial
     * mocking of system classes.
     */
    private String getReplicaMethodDelegationCode(Class<?> clazz, CtMethod ctMethod, String classOrInstanceToDelegateTo)
            throws NotFoundException {
        StringBuilder builder = new StringBuilder();
        builder.append("{java.lang.reflect.Method originalMethod = ");
        builder.append(clazz.getName());
        builder.append(".class.getDeclaredMethod(\"");
        builder.append(ctMethod.getName());
        builder.append("\", ");
        final String parametersAsString = getParametersAsString(getParameterTypes(ctMethod));
        if ("".equals(parametersAsString)) {
            builder.append("null");
        } else {
            builder.append(parametersAsString);
        }
        builder.append(");\n");
        builder.append("originalMethod.setAccessible(true);\n");
        final CtClass returnType = ctMethod.getReturnType();
        final boolean isVoid = returnType.equals(CtClass.voidType);
        if (!isVoid) {
            builder.append("return (");
            builder.append(returnType.getName());
            builder.append(") ");
        }
        builder.append("originalMethod.invoke(");
        if (Modifier.isStatic(ctMethod.getModifiers()) || classOrInstanceToDelegateTo == null) {
            builder.append(clazz.getName());
            builder.append(".class");
        } else {
            builder.append(classOrInstanceToDelegateTo);
        }
        builder.append(", $args);}");
        return builder.toString();
    }

    private String[] getParameterTypes(CtMethod ctMethod) throws NotFoundException {
        final CtClass[] parameterTypesAsCtClass = ctMethod.getParameterTypes();
        final String[] parameterTypes = new String[parameterTypesAsCtClass.length];
        for (int i = 0; i < parameterTypes.length; i++) {
            parameterTypes[i] = parameterTypesAsCtClass[i].getName() + ".class";
        }
        return parameterTypes;
    }

    private static String getParametersAsString(String[] types) {
        StringBuilder parametersAsString = new StringBuilder();
        if (types != null && types.length == 0) {
            parametersAsString.append("new Class[0]");
        } else {
            parametersAsString.append("new Class[] {");
            if (types != null) {
                for (int i = 0; i < types.length; i++) {
                    parametersAsString.append(types[i]);
                    if (i != types.length - 1) {
                        parametersAsString.append(", ");
                    }
                }
            }
            parametersAsString.append("}");
        }
        return parametersAsString.toString();
    }
}
