/*
 * Copyright (C) 2011 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.lint.detector.api;

import static com.android.SdkConstants.CONSTRUCTOR_NAME;
import static com.android.SdkConstants.DOT_CLASS;
import static com.android.SdkConstants.DOT_JAVA;
import static com.android.tools.lint.detector.api.Location.SearchDirection.BACKWARD;
import static com.android.tools.lint.detector.api.Location.SearchDirection.EOL_BACKWARD;
import static com.android.tools.lint.detector.api.Location.SearchDirection.FORWARD;

import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.tools.lint.client.api.LintDriver;
import com.android.tools.lint.detector.api.Location.SearchDirection;
import com.android.tools.lint.detector.api.Location.SearchHints;
import com.google.common.annotations.Beta;
import com.google.common.base.Splitter;

import org.objectweb.asm.Type;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.FieldNode;
import org.objectweb.asm.tree.LineNumberNode;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;

import java.io.File;
import java.util.List;

/**
 * A {@link Context} used when checking .class files.
 * <p>
 * <b>NOTE: This is not a public or final API; if you rely on this be prepared
 * to adjust your code for the next tools release.</b>
 */
@Beta
public class ClassContext extends Context {
    private final File mBinDir;
    /** The class file DOM root node */
    private final ClassNode mClassNode;
    /** The class file byte data */
    private final byte[] mBytes;
    /** The source file, if known/found */
    private File mSourceFile;
    /** The contents of the source file, if source file is known/found */
    private String mSourceContents;
    /** Whether we've searched for the source file (used to avoid repeated failed searches) */
    private boolean mSearchedForSource;
    /** If the file is a relative path within a jar file, this is the jar file, otherwise null */
    private final File mJarFile;
    /** Whether this class is part of a library (rather than corresponding to one of the
     * source files in this project */
    private final boolean mFromLibrary;

    /**
     * Construct a new {@link ClassContext}
     *
     * @param driver the driver running through the checks
     * @param project the project containing the file being checked
     * @param main the main project if this project is a library project, or
     *            null if this is not a library project. The main project is the
     *            root project of all library projects, not necessarily the
     *            directly including project.
     * @param file the file being checked
     * @param jarFile If the file is a relative path within a jar file, this is
     *            the jar file, otherwise null
     * @param binDir the root binary directory containing this .class file.
     * @param bytes the bytecode raw data
     * @param classNode the bytecode object model
     * @param fromLibrary whether this class is from a library rather than part
     *            of this project
     * @param sourceContents initial contents of the Java source, if known, or
     *            null
     */
    public ClassContext(
            @NonNull LintDriver driver,
            @NonNull Project project,
            @Nullable Project main,
            @NonNull File file,
            @Nullable File jarFile,
            @NonNull File binDir,
            @NonNull byte[] bytes,
            @NonNull ClassNode classNode,
            boolean fromLibrary,
            @Nullable String sourceContents) {
        super(driver, project, main, file);
        mJarFile = jarFile;
        mBinDir = binDir;
        mBytes = bytes;
        mClassNode = classNode;
        mFromLibrary = fromLibrary;
        mSourceContents = sourceContents;
    }

    /**
     * Returns the raw bytecode data for this class file
     *
     * @return the byte array containing the bytecode data
     */
    @NonNull
    public byte[] getBytecode() {
        return mBytes;
    }

    /**
     * Returns the bytecode object model
     *
     * @return the bytecode object model, never null
     */
    @NonNull
    public ClassNode getClassNode() {
        return mClassNode;
    }

    /**
     * Returns the jar file, if any. If this is null, the .class file is a real file
     * on disk, otherwise it represents a relative path within the jar file.
     *
     * @return the jar file, or null
     */
    @Nullable
    public File getJarFile() {
        return mJarFile;
    }

    /**
     * Returns whether this class is part of a library (not this project).
     *
     * @return true if this class is part of a library
     */
    public boolean isFromClassLibrary() {
        return mFromLibrary;
    }

    /**
     * Returns the source file for this class file, if possible.
     *
     * @return the source file, or null
     */
    @Nullable
    public File getSourceFile() {
        if (mSourceFile == null && !mSearchedForSource) {
            mSearchedForSource = true;

            String source = mClassNode.sourceFile;
            if (source == null) {
                source = file.getName();
                if (source.endsWith(DOT_CLASS)) {
                    source = source.substring(0, source.length() - DOT_CLASS.length()) + DOT_JAVA;
                }
                int index = source.indexOf('$');
                if (index != -1) {
                    source = source.substring(0, index) + DOT_JAVA;
                }
            }
            if (source != null) {
                if (mJarFile != null) {
                    String relative = file.getParent() + File.separator + source;
                    List<File> sources = getProject().getJavaSourceFolders();
                    for (File dir : sources) {
                        File sourceFile = new File(dir, relative);
                        if (sourceFile.exists()) {
                            mSourceFile = sourceFile;
                            break;
                        }
                    }
                } else {
                    // Determine package
                    String topPath = mBinDir.getPath();
                    String parentPath = file.getParentFile().getPath();
                    if (parentPath.startsWith(topPath)) {
                        int start = topPath.length() + 1;
                        String relative = start > parentPath.length() ? // default package?
                                "" : parentPath.substring(start);
                        List<File> sources = getProject().getJavaSourceFolders();
                        for (File dir : sources) {
                            File sourceFile = new File(dir, relative + File.separator + source);
                            if (sourceFile.exists()) {
                                mSourceFile = sourceFile;
                                break;
                            }
                        }
                    }
                }
            }
        }

        return mSourceFile;
    }

    /**
     * Returns the contents of the source file for this class file, if found.
     *
     * @return the source contents, or ""
     */
    @NonNull
    public String getSourceContents() {
        if (mSourceContents == null) {
            File sourceFile = getSourceFile();
            if (sourceFile != null) {
                mSourceContents = getClient().readFile(mSourceFile);
            }

            if (mSourceContents == null) {
                mSourceContents = "";
            }
        }

        return mSourceContents;
    }

    /**
     * Returns the contents of the source file for this class file, if found. If
     * {@code read} is false, do not read the source contents if it has not
     * already been read. (This is primarily intended for the lint
     * infrastructure; most client code would call {@link #getSourceContents()}
     * .)
     *
     * @param read whether to read the source contents if it has not already
     *            been initialized
     * @return the source contents, which will never be null if {@code read} is
     *         true, or null if {@code read} is false and the source contents
     *         hasn't already been read.
     */
    @Nullable
    public String getSourceContents(boolean read) {
        if (read) {
            return getSourceContents();
        } else {
            return mSourceContents;
        }
    }

    /**
     * Returns a location for the given source line number in this class file's
     * source file, if available.
     *
     * @param line the line number (1-based, which is what ASM uses)
     * @param patternStart optional pattern to search for in the source for
     *            range start
     * @param patternEnd optional pattern to search for in the source for range
     *            end
     * @param hints additional hints about the pattern search (provided
     *            {@code patternStart} is non null)
     * @return a location, never null
     */
    @NonNull
    public Location getLocationForLine(int line, @Nullable String patternStart,
            @Nullable String patternEnd, @Nullable SearchHints hints) {
        File sourceFile = getSourceFile();
        if (sourceFile != null) {
            // ASM line numbers are 1-based, and lint line numbers are 0-based
            if (line != -1) {
                return Location.create(sourceFile, getSourceContents(), line - 1,
                        patternStart, patternEnd, hints);
            } else {
                return Location.create(sourceFile);
            }
        }

        return Location.create(file);
    }

    /**
     * Reports an issue.
     * <p>
     * Detectors should only call this method if an error applies to the whole class
     * scope and there is no specific method or field that applies to the error.
     * If so, use
     * {@link #report(Issue, org.objectweb.asm.tree.MethodNode, org.objectweb.asm.tree.AbstractInsnNode, Location, String)} or
     * {@link #report(Issue, org.objectweb.asm.tree.FieldNode, Location, String)}, such that
     * suppress annotations are checked.
     *
     * @param issue the issue to report
     * @param location the location of the issue, or null if not known
     * @param message the message for this warning
     */
    @Override
    public void report(
            @NonNull Issue issue,
            @NonNull Location location,
            @NonNull String message) {
        if (mDriver.isSuppressed(issue, mClassNode)) {
            return;
        }
        ClassNode curr = mClassNode;
        while (curr != null) {
            ClassNode prev = curr;
            curr = mDriver.getOuterClassNode(curr);
            if (curr != null) {
                if (prev.outerMethod != null) {
                    @SuppressWarnings("rawtypes") // ASM API
                    List methods = curr.methods;
                    for (Object m : methods) {
                        MethodNode method = (MethodNode) m;
                        if (method.name.equals(prev.outerMethod)
                                && method.desc.equals(prev.outerMethodDesc)) {
                            // Found the outer method for this anonymous class; continue
                            // reporting on it (which will also work its way up the parent
                            // class hierarchy)
                            if (method != null && mDriver.isSuppressed(issue, mClassNode, method,
                                    null)) {
                                return;
                            }
                            break;
                        }
                    }
                }
                if (mDriver.isSuppressed(issue, curr)) {
                    return;
                }
            }
        }

        super.report(issue, location, message);
    }

    // Unfortunately, ASMs nodes do not extend a common DOM node type with parent
    // pointers, so we have to have multiple methods which pass in each type
    // of node (class, method, field) to be checked.

    /**
     * Reports an issue applicable to a given method node.
     *
     * @param issue the issue to report
     * @param method the method scope the error applies to. The lint
     *            infrastructure will check whether there are suppress
     *            annotations on this method (or its enclosing class) and if so
     *            suppress the warning without involving the client.
     * @param instruction the instruction within the method the error applies
     *            to. You cannot place annotations on individual method
     *            instructions (for example, annotations on local variables are
     *            allowed, but are not kept in the .class file). However, this
     *            instruction is needed to handle suppressing errors on field
     *            initializations; in that case, the errors may be reported in
     *            the {@code <clinit>} method, but the annotation is found not
     *            on that method but for the {@link FieldNode}'s.
     * @param location the location of the issue, or null if not known
     * @param message the message for this warning
     */
    public void report(
            @NonNull Issue issue,
            @Nullable MethodNode method,
            @Nullable AbstractInsnNode instruction,
            @NonNull Location location,
            @NonNull String message) {
        if (method != null && mDriver.isSuppressed(issue, mClassNode, method, instruction)) {
            return;
        }
        report(issue, location, message); // also checks the class node
    }

    /**
     * Reports an issue applicable to a given method node.
     *
     * @param issue the issue to report
     * @param field the scope the error applies to. The lint infrastructure
     *    will check whether there are suppress annotations on this field (or its enclosing
     *    class) and if so suppress the warning without involving the client.
     * @param location the location of the issue, or null if not known
     * @param message the message for this warning
     */
    public void report(
            @NonNull Issue issue,
            @Nullable FieldNode field,
            @NonNull Location location,
            @NonNull String message) {
        if (field != null && mDriver.isSuppressed(issue, field)) {
            return;
        }
        report(issue, location, message); // also checks the class node
    }

    /**
     * Report an error.
     * Like {@link #report(Issue, MethodNode, AbstractInsnNode, Location, String)} but with
     * a now-unused data parameter at the end.
     *
     * @deprecated Use {@link #report(Issue, FieldNode, Location, String)} instead;
     *    this method is here for custom rule compatibility
     */
    @SuppressWarnings("UnusedDeclaration") // Potentially used by external existing custom rules
    @Deprecated
    public void report(
            @NonNull Issue issue,
            @Nullable MethodNode method,
            @Nullable AbstractInsnNode instruction,
            @NonNull Location location,
            @NonNull String message,
            @SuppressWarnings("UnusedParameters") @Nullable Object data) {
        report(issue, method, instruction, location, message);
    }

    /**
     * Report an error.
     * Like {@link #report(Issue, FieldNode, Location, String)} but with
     * a now-unused data parameter at the end.
     *
     * @deprecated Use {@link #report(Issue, FieldNode, Location, String)} instead;
     *    this method is here for custom rule compatibility
     */
    @SuppressWarnings("UnusedDeclaration") // Potentially used by external existing custom rules
    @Deprecated
    public void report(
            @NonNull Issue issue,
            @Nullable FieldNode field,
            @NonNull Location location,
            @NonNull String message,
            @SuppressWarnings("UnusedParameters") @Nullable Object data) {
        report(issue, field, location, message);
    }

    /**
     * Finds the line number closest to the given node
     *
     * @param node the instruction node to get a line number for
     * @return the closest line number, or -1 if not known
     */
    public static int findLineNumber(@NonNull AbstractInsnNode node) {
        AbstractInsnNode curr = node;

        // First search backwards
        while (curr != null) {
            if (curr.getType() == AbstractInsnNode.LINE) {
                return ((LineNumberNode) curr).line;
            }
            curr = curr.getPrevious();
        }

        // Then search forwards
        curr = node;
        while (curr != null) {
            if (curr.getType() == AbstractInsnNode.LINE) {
                return ((LineNumberNode) curr).line;
            }
            curr = curr.getNext();
        }

        return -1;
    }

    /**
     * Finds the line number closest to the given method declaration
     *
     * @param node the method node to get a line number for
     * @return the closest line number, or -1 if not known
     */
    public static int findLineNumber(@NonNull MethodNode node) {
        if (node.instructions != null && node.instructions.size() > 0) {
            return findLineNumber(node.instructions.get(0));
        }

        return -1;
    }

    /**
     * Finds the line number closest to the given class declaration
     *
     * @param node the method node to get a line number for
     * @return the closest line number, or -1 if not known
     */
    public static int findLineNumber(@NonNull ClassNode node) {
        if (node.methods != null && !node.methods.isEmpty()) {
            MethodNode firstMethod = getFirstRealMethod(node);
            if (firstMethod != null) {
                return findLineNumber(firstMethod);
            }
        }

        return -1;
    }

    /**
     * Returns a location for the given {@link ClassNode}, where class node is
     * either the top level class, or an inner class, in the current context.
     *
     * @param classNode the class in the current context
     * @return a location pointing to the class declaration, or as close to it
     *         as possible
     */
    @NonNull
    public Location getLocation(@NonNull ClassNode classNode) {
        // Attempt to find a proper location for this class. This is tricky
        // since classes do not have line number entries in the class file; we need
        // to find a method, look up the corresponding line number then search
        // around it for a suitable tag, such as the class name.
        String pattern;
        if (isAnonymousClass(classNode.name)) {
            pattern = classNode.superName;
        } else {
            pattern = classNode.name;
        }
        int index = pattern.lastIndexOf('$');
        if (index != -1) {
            pattern = pattern.substring(index + 1);
        }
        index = pattern.lastIndexOf('/');
        if (index != -1) {
            pattern = pattern.substring(index + 1);
        }

        return getLocationForLine(findLineNumber(classNode), pattern, null,
                SearchHints.create(BACKWARD).matchJavaSymbol());
    }

    @Nullable
    private static MethodNode getFirstRealMethod(@NonNull ClassNode classNode) {
        // Return the first method in the class for line number purposes. Skip <init>,
        // since it's typically not located near the real source of the method.
        if (classNode.methods != null) {
            @SuppressWarnings("rawtypes") // ASM API
            List methods = classNode.methods;
            for (Object m : methods) {
                MethodNode method = (MethodNode) m;
                if (method.name.charAt(0) != '<') {
                    return method;
                }
            }

            if (!classNode.methods.isEmpty()) {
                return (MethodNode) classNode.methods.get(0);
            }
        }

        return null;
    }

    /**
     * Returns a location for the given {@link MethodNode}.
     *
     * @param methodNode the class in the current context
     * @param classNode the class containing the method
     * @return a location pointing to the class declaration, or as close to it
     *         as possible
     */
    @NonNull
    public Location getLocation(@NonNull MethodNode methodNode,
            @NonNull ClassNode classNode) {
        // Attempt to find a proper location for this class. This is tricky
        // since classes do not have line number entries in the class file; we need
        // to find a method, look up the corresponding line number then search
        // around it for a suitable tag, such as the class name.
        String pattern;
        SearchDirection searchMode;
        if (methodNode.name.equals(CONSTRUCTOR_NAME)) {
            searchMode = EOL_BACKWARD;
            if (isAnonymousClass(classNode.name)) {
                pattern = classNode.superName.substring(classNode.superName.lastIndexOf('/') + 1);
            } else {
                pattern = classNode.name.substring(classNode.name.lastIndexOf('$') + 1);
            }
        } else {
            searchMode = BACKWARD;
            pattern = methodNode.name;
        }

        return getLocationForLine(findLineNumber(methodNode), pattern, null,
                SearchHints.create(searchMode).matchJavaSymbol());
    }

    /**
     * Returns a location for the given {@link AbstractInsnNode}.
     *
     * @param instruction the instruction to look up the location for
     * @return a location pointing to the instruction, or as close to it
     *         as possible
     */
    @NonNull
    public Location getLocation(@NonNull AbstractInsnNode instruction) {
        SearchHints hints = SearchHints.create(FORWARD).matchJavaSymbol();
        String pattern = null;
        if (instruction instanceof MethodInsnNode) {
            MethodInsnNode call = (MethodInsnNode) instruction;
            if (call.name.equals(CONSTRUCTOR_NAME)) {
                pattern = call.owner;
                hints = hints.matchConstructor();
            } else {
                pattern = call.name;
            }
            int index = pattern.lastIndexOf('$');
            if (index != -1) {
                pattern = pattern.substring(index + 1);
            }
            index = pattern.lastIndexOf('/');
            if (index != -1) {
                pattern = pattern.substring(index + 1);
            }
        }

        int line = findLineNumber(instruction);
        return getLocationForLine(line, pattern, null, hints);
    }

    private static boolean isAnonymousClass(@NonNull String fqcn) {
        int lastIndex = fqcn.lastIndexOf('$');
        if (lastIndex != -1 && lastIndex < fqcn.length() - 1) {
            if (Character.isDigit(fqcn.charAt(lastIndex + 1))) {
                return true;
            }
        }
        return false;
    }

    /**
     * Converts from a VM owner name (such as foo/bar/Foo$Baz) to a
     * fully qualified class name (such as foo.bar.Foo.Baz).
     *
     * @param owner the owner name to convert
     * @return the corresponding fully qualified class name
     */
    @NonNull
    public static String getFqcn(@NonNull String owner) {
        return owner.replace('/', '.').replace('$','.');
    }

    /**
     * Computes a user-readable type signature from the given class owner, name
     * and description. For example, for owner="foo/bar/Foo$Baz", name="foo",
     * description="(I)V", it returns "void foo.bar.Foo.Bar#foo(int)".
     *
     * @param owner the class name
     * @param name the method name
     * @param desc the method description
     * @return a user-readable string
     */
    public static String createSignature(String owner, String name, String desc) {
        StringBuilder sb = new StringBuilder(100);

        if (desc != null) {
            Type returnType = Type.getReturnType(desc);
            sb.append(getTypeString(returnType));
            sb.append(' ');
        }

        if (owner != null) {
            sb.append(getFqcn(owner));
        }
        if (name != null) {
            sb.append('#');
            sb.append(name);
            if (desc != null) {
                Type[] argumentTypes = Type.getArgumentTypes(desc);
                if (argumentTypes != null && argumentTypes.length > 0) {
                    sb.append('(');
                    boolean first = true;
                    for (Type type : argumentTypes) {
                        if (first) {
                            first = false;
                        } else {
                            sb.append(", ");
                        }
                        sb.append(getTypeString(type));
                    }
                    sb.append(')');
                }
            }
        }

        return sb.toString();
    }

    private static String getTypeString(Type type) {
        String s = type.getClassName();
        if (s.startsWith("java.lang.")) {           //$NON-NLS-1$
            s = s.substring("java.lang.".length()); //$NON-NLS-1$
        }

        return s;
    }

    /**
     * Computes the internal class name of the given fully qualified class name.
     * For example, it converts foo.bar.Foo.Bar into foo/bar/Foo$Bar
     *
     * @param fqcn the fully qualified class name
     * @return the internal class name
     */
    @NonNull
    public static String getInternalName(@NonNull String fqcn) {
        if (fqcn.indexOf('.') == -1) {
            return fqcn;
        }

        // If class name contains $, it's not an ambiguous inner class name.
        if (fqcn.indexOf('$') != -1) {
            return fqcn.replace('.', '/');
        }
        // Let's assume that components that start with Caps are class names.
        StringBuilder sb = new StringBuilder(fqcn.length());
        String prev = null;
        for (String part : Splitter.on('.').split(fqcn)) {
            if (prev != null && !prev.isEmpty()) {
                if (Character.isUpperCase(prev.charAt(0))) {
                    sb.append('$');
                } else {
                    sb.append('/');
                }
            }
            sb.append(part);
            prev = part;
        }

        return sb.toString();
    }
}
