/*
 * Copyright (C) 2008 The Android Open Source Project
 *
 * 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 com.android.tools.layoutlib.create;

import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;

import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.Map.Entry;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;

/**
 * Class that generates a new JAR from a list of classes, some of which are to be kept as-is
 * and some of which are to be stubbed partially or totally.
 */
public class AsmGenerator {

    /** Output logger. */
    private final Log mLog;
    /** The path of the destination JAR to create. */
    private final String mOsDestJar;
    /** List of classes to inject in the final JAR from _this_ archive. */
    private final Class<?>[] mInjectClasses;
    /** The set of methods to stub out. */
    private final Set<String> mStubMethods;
    /** All classes to output as-is, except if they have native methods. */
    private Map<String, ClassReader> mKeep;
    /** All dependencies that must be completely stubbed. */
    private Map<String, ClassReader> mDeps;
    /** Counter of number of classes renamed during transform. */
    private int mRenameCount;
    /** FQCN Names of the classes to rename: map old-FQCN => new-FQCN */
    private final HashMap<String, String> mRenameClasses;
    /** FQCN Names of "old" classes that were NOT renamed. This starts with the full list of
     *  old-FQCN to rename and they get erased as they get renamed. At the end, classes still
     *  left here are not in the code base anymore and thus were not renamed. */
    private HashSet<String> mClassesNotRenamed;
    /** A map { FQCN => map { list of return types to delete from the FQCN } }. */
    private HashMap<String, Set<String>> mDeleteReturns;

    /**
     * Creates a new generator that can generate the output JAR with the stubbed classes.
     * 
     * @param log Output logger.
     * @param osDestJar The path of the destination JAR to create.
     * @param injectClasses The list of class from layoutlib_create to inject in layoutlib.
     * @param stubMethods The list of methods to stub out. Each entry must be in the form
     *          "package.package.OuterClass$InnerClass#MethodName".
     * @param renameClasses The list of classes to rename, must be an even list: the binary FQCN
     *          of class to replace followed by the new FQCN.
     * @param deleteReturns List of classes for which the methods returning them should be deleted.
     * The array contains a list of null terminated section starting with the name of the class
     * to rename in which the methods are deleted, followed by a list of return types identifying
     * the methods to delete.
     */
    public AsmGenerator(Log log, String osDestJar,
            Class<?>[] injectClasses,
            String[] stubMethods,
            String[] renameClasses, String[] deleteReturns) {
        mLog = log;
        mOsDestJar = osDestJar;
        mInjectClasses = injectClasses != null ? injectClasses : new Class<?>[0];
        mStubMethods = stubMethods != null ? new HashSet<String>(Arrays.asList(stubMethods)) :
                                             new HashSet<String>();

        // Create the map of classes to rename.
        mRenameClasses = new HashMap<String, String>();
        mClassesNotRenamed = new HashSet<String>();
        int n = renameClasses == null ? 0 : renameClasses.length;
        for (int i = 0; i < n; i += 2) {
            assert i + 1 < n;
            // The ASM class names uses "/" separators, whereas regular FQCN use "."
            String oldFqcn = binaryToInternalClassName(renameClasses[i]);
            String newFqcn = binaryToInternalClassName(renameClasses[i + 1]);
            mRenameClasses.put(oldFqcn, newFqcn);
            mClassesNotRenamed.add(oldFqcn);
        }
        
        // create the map of renamed class -> return type of method to delete.
        mDeleteReturns = new HashMap<String, Set<String>>();
        if (deleteReturns != null) {
            Set<String> returnTypes = null;
            String renamedClass = null;
            for (String className : deleteReturns) {
                // if we reach the end of a section, add it to the main map
                if (className == null) {
                    if (returnTypes != null) {
                        mDeleteReturns.put(renamedClass, returnTypes);
                    }
                    
                    renamedClass = null;
                    continue;
                }
    
                // if the renamed class is null, this is the beginning of a section
                if (renamedClass == null) {
                    renamedClass = binaryToInternalClassName(className);
                    continue;
                }
    
                // just a standard return type, we add it to the list.
                if (returnTypes == null) {
                    returnTypes = new HashSet<String>();
                }
                returnTypes.add(binaryToInternalClassName(className));
            }
        }
    }
    
    /**
     * Returns the list of classes that have not been renamed yet.
     * <p/>
     * The names are "internal class names" rather than FQCN, i.e. they use "/" instead "."
     * as package separators.
     */
    public Set<String> getClassesNotRenamed() {
        return mClassesNotRenamed;
    }

    /**
     * Utility that returns the internal ASM class name from a fully qualified binary class
     * name. E.g. it returns android/view/View from android.view.View.
     */
    String binaryToInternalClassName(String className) {
        if (className == null) {
            return null;
        } else {
            return className.replace('.', '/');
        }
    }

    /** Sets the map of classes to output as-is, except if they have native methods */
    public void setKeep(Map<String, ClassReader> keep) {
        mKeep = keep;
    }

    /** Sets the map of dependencies that must be completely stubbed */
    public void setDeps(Map<String, ClassReader> deps) {
        mDeps = deps;
    }
    
    /** Gets the map of classes to output as-is, except if they have native methods */
    public Map<String, ClassReader> getKeep() {
        return mKeep;
    }
    
    /** Gets the map of dependencies that must be completely stubbed */
    public Map<String, ClassReader> getDeps() {
        return mDeps;
    }

    /** Generates the final JAR */
    public void generate() throws FileNotFoundException, IOException {
        TreeMap<String, byte[]> all = new TreeMap<String, byte[]>();
        
        for (Class<?> clazz : mInjectClasses) {
            String name = classToEntryPath(clazz);
            InputStream is = ClassLoader.getSystemResourceAsStream(name);
            ClassReader cr = new ClassReader(is);
            byte[] b = transform(cr, true /* stubNativesOnly */);
            name = classNameToEntryPath(transformName(cr.getClassName()));
            all.put(name, b);
        }
        
        for (Entry<String, ClassReader> entry : mDeps.entrySet()) {
            ClassReader cr = entry.getValue();
            byte[] b = transform(cr, true /* stubNativesOnly */);
            String name = classNameToEntryPath(transformName(cr.getClassName()));
            all.put(name, b);
        }

        for (Entry<String, ClassReader> entry : mKeep.entrySet()) {
            ClassReader cr = entry.getValue();
            byte[] b = transform(cr, true /* stubNativesOnly */);
            String name = classNameToEntryPath(transformName(cr.getClassName()));
            all.put(name, b);
        }

        mLog.info("# deps classes: %d", mDeps.size());
        mLog.info("# keep classes: %d", mKeep.size());
        mLog.info("# renamed     : %d", mRenameCount);

        createJar(new FileOutputStream(mOsDestJar), all);
        mLog.info("Created JAR file %s", mOsDestJar);
    }

    /**
     * Writes the JAR file.
     * 
     * @param outStream The file output stream were to write the JAR. 
     * @param all The map of all classes to output.
     * @throws IOException if an I/O error has occurred
     */
    void createJar(FileOutputStream outStream, Map<String,byte[]> all) throws IOException {
        JarOutputStream jar = new JarOutputStream(outStream);
        for (Entry<String, byte[]> entry : all.entrySet()) {
            String name = entry.getKey();
            JarEntry jar_entry = new JarEntry(name);
            jar.putNextEntry(jar_entry);
            jar.write(entry.getValue());
            jar.closeEntry();
        }
        jar.flush();
        jar.close();
    }

    /**
     * Utility method that converts a fully qualified java name into a JAR entry path
     * e.g. for the input "android.view.View" it returns "android/view/View.class"
     */
    String classNameToEntryPath(String className) {
        return className.replaceAll("\\.", "/").concat(".class");
    }
    
    /**
     * Utility method to get the JAR entry path from a Class name.
     * e.g. it returns someting like "com/foo/OuterClass$InnerClass1$InnerClass2.class"
     */
    private String classToEntryPath(Class<?> clazz) {
        String name = "";
        Class<?> parent;
        while ((parent = clazz.getEnclosingClass()) != null) {
            name = "$" + clazz.getSimpleName() + name;
            clazz = parent;
        }
        return classNameToEntryPath(clazz.getCanonicalName() + name);        
    }

    /**
     * Transforms a class.
     * <p/>
     * There are 3 kind of transformations:
     * 
     * 1- For "mock" dependencies classes, we want to remove all code from methods and replace
     * by a stub. Native methods must be implemented with this stub too. Abstract methods are
     * left intact. Modified classes must be overridable (non-private, non-final).
     * Native methods must be made non-final, non-private.
     * 
     * 2- For "keep" classes, we want to rewrite all native methods as indicated above.
     * If a class has native methods, it must also be made non-private, non-final.
     * 
     * Note that unfortunately static methods cannot be changed to non-static (since static and
     * non-static are invoked differently.)
     */
    byte[] transform(ClassReader cr, boolean stubNativesOnly) {

        boolean hasNativeMethods = hasNativeMethods(cr);
        String className = cr.getClassName();
        
        String newName = transformName(className);
        // transformName returns its input argument if there's no need to rename the class
        if (newName != className) {
            mRenameCount++;
            // This class is being renamed, so remove it from the list of classes not renamed.
            mClassesNotRenamed.remove(className);
        }

        mLog.debug("Transform %s%s%s%s", className,
                newName == className ? "" : " (renamed to " + newName + ")",
                hasNativeMethods ? " -- has natives" : "",
                stubNativesOnly ? " -- stub natives only" : "");

        // Rewrite the new class from scratch, without reusing the constant pool from the
        // original class reader.
        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
        
        ClassVisitor rv = cw;
        if (newName != className) {
            rv = new RenameClassAdapter(cw, className, newName);
        }
        
        TransformClassAdapter cv = new TransformClassAdapter(mLog, mStubMethods, 
                mDeleteReturns.get(className),
                newName, rv,
                stubNativesOnly, stubNativesOnly || hasNativeMethods);
        cr.accept(cv, 0 /* flags */);
        return cw.toByteArray();
    }

    /**
     * Should this class be renamed, this returns the new name. Otherwise it returns the
     * original name.
     *
     * @param className The internal ASM name of the class that may have to be renamed
     * @return A new transformed name or the original input argument.
     */
    String transformName(String className) {
        String newName = mRenameClasses.get(className);
        if (newName != null) {
            return newName;
        }
        int pos = className.indexOf('$');
        if (pos > 0) {
            // Is this an inner class of a renamed class?
            String base = className.substring(0, pos);
            newName = mRenameClasses.get(base);
            if (newName != null) {
                return newName + className.substring(pos);
            }
        }
        
        return className;
    }

    /**
     * Returns true if a class has any native methods.
     */
    boolean hasNativeMethods(ClassReader cr) {
        ClassHasNativeVisitor cv = new ClassHasNativeVisitor();
        cr.accept(cv, 0 /* flags */);
        return cv.hasNativeMethods();
    }

}
