package org.checkerframework.javacutil;

import com.sun.source.util.TreePath;
import com.sun.source.util.Trees;
import com.sun.tools.javac.api.JavacScope;
import com.sun.tools.javac.code.Kinds;
import com.sun.tools.javac.code.Kinds.KindSelector;
import com.sun.tools.javac.code.Symbol;
import com.sun.tools.javac.code.Symbol.ClassSymbol;
import com.sun.tools.javac.code.Symbol.PackageSymbol;
import com.sun.tools.javac.code.Symbol.TypeSymbol;
import com.sun.tools.javac.code.Type;
import com.sun.tools.javac.comp.AttrContext;
import com.sun.tools.javac.comp.DeferredAttr;
import com.sun.tools.javac.comp.Env;
import com.sun.tools.javac.comp.Resolve;
import com.sun.tools.javac.processing.JavacProcessingEnvironment;
import com.sun.tools.javac.util.Context;
import com.sun.tools.javac.util.List;
import com.sun.tools.javac.util.Log;
import com.sun.tools.javac.util.Name;
import com.sun.tools.javac.util.Names;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import javax.annotation.processing.ProcessingEnvironment;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.VariableElement;
import javax.lang.model.type.TypeMirror;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;

/** A Utility class to find symbols corresponding to string references. */
// This class reflectively accesses jdk.compiler/com.sun.tools.javac.comp.
// This is why --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED is required when
// running the Checker Framework.  If this class is re-written, then that --add-opens should be
// removed.
public class Resolver {
    private final Resolve resolve;
    private final Names names;
    private final Trees trees;
    private final Log log;

    private static final Method FIND_METHOD;
    private static final Method FIND_VAR;
    private static final Method FIND_IDENT;
    private static final Method FIND_IDENT_IN_TYPE;
    private static final Method FIND_IDENT_IN_PACKAGE;
    private static final Method FIND_TYPE;

    private static final Class<?> ACCESSERROR;
    // Note that currently access(...) is defined in InvalidSymbolError, a superclass of AccessError
    private static final Method ACCESSERROR_ACCESS;

    static {
        try {
            FIND_METHOD =
                    Resolve.class.getDeclaredMethod(
                            "findMethod",
                            Env.class,
                            Type.class,
                            Name.class,
                            List.class,
                            List.class,
                            boolean.class,
                            boolean.class);
            FIND_METHOD.setAccessible(true);

            FIND_VAR = Resolve.class.getDeclaredMethod("findVar", Env.class, Name.class);
            FIND_VAR.setAccessible(true);

            FIND_IDENT =
                    Resolve.class.getDeclaredMethod(
                            "findIdent", Env.class, Name.class, KindSelector.class);
            FIND_IDENT.setAccessible(true);

            FIND_IDENT_IN_TYPE =
                    Resolve.class.getDeclaredMethod(
                            "findIdentInType",
                            Env.class,
                            Type.class,
                            Name.class,
                            KindSelector.class);
            FIND_IDENT_IN_TYPE.setAccessible(true);

            FIND_IDENT_IN_PACKAGE =
                    Resolve.class.getDeclaredMethod(
                            "findIdentInPackage",
                            Env.class,
                            TypeSymbol.class,
                            Name.class,
                            KindSelector.class);
            FIND_IDENT_IN_PACKAGE.setAccessible(true);

            FIND_TYPE = Resolve.class.getDeclaredMethod("findType", Env.class, Name.class);
            FIND_TYPE.setAccessible(true);
        } catch (Exception e) {
            Error err =
                    new AssertionError(
                            "Compiler 'Resolve' class doesn't contain required 'find' method");
            err.initCause(e);
            throw err;
        }

        try {
            ACCESSERROR = Class.forName("com.sun.tools.javac.comp.Resolve$AccessError");
            ACCESSERROR_ACCESS = ACCESSERROR.getMethod("access", Name.class, TypeSymbol.class);
            ACCESSERROR_ACCESS.setAccessible(true);
        } catch (ClassNotFoundException e) {
            throw new BugInCF("Compiler 'Resolve$AccessError' class could not be retrieved.", e);
        } catch (NoSuchMethodException e) {
            throw new BugInCF(
                    "Compiler 'Resolve$AccessError' class doesn't contain required 'access' method",
                    e);
        }
    }

    public Resolver(ProcessingEnvironment env) {
        Context context = ((JavacProcessingEnvironment) env).getContext();
        this.resolve = Resolve.instance(context);
        this.names = Names.instance(context);
        this.trees = Trees.instance(env);
        this.log = Log.instance(context);
    }

    /**
     * Determine the environment for the given path.
     *
     * @param path the tree path to the local scope
     * @return the corresponding attribution environment
     */
    public Env<AttrContext> getEnvForPath(TreePath path) {
        TreePath iter = path;
        JavacScope scope = null;
        while (scope == null && iter != null) {
            try {
                scope = (JavacScope) trees.getScope(iter);
            } catch (Throwable t) {
                // Work around Issue #1059 by skipping through the TreePath until something
                // doesn't crash. This probably returns the class scope, so users might not
                // get the variables they expect. But that is better than crashing.
                iter = iter.getParentPath();
            }
        }
        if (scope != null) {
            return scope.getEnv();
        } else {
            throw new BugInCF("Could not determine any possible scope for path: " + path.getLeaf());
        }
    }

    /**
     * Finds the package with name {@code name}.
     *
     * @param name the name of the package
     * @param path the tree path to the local scope
     * @return the {@code PackageSymbol} for the package if it is found, {@code null} otherwise
     */
    public @Nullable PackageSymbol findPackage(String name, TreePath path) {
        Log.DiagnosticHandler discardDiagnosticHandler = new Log.DiscardDiagnosticHandler(log);
        try {
            Env<AttrContext> env = getEnvForPath(path);
            Element res =
                    wrapInvocationOnResolveInstance(
                            FIND_IDENT, env, names.fromString(name), Kinds.KindSelector.PCK);
            // findIdent will return a PackageSymbol even for a symbol that is not a package,
            // such as a.b.c.MyClass.myStaticField. "exists()" must be called on it to ensure
            // that it exists.
            if (res.getKind() == ElementKind.PACKAGE) {
                PackageSymbol ps = (PackageSymbol) res;
                return ps.exists() ? ps : null;
            } else {
                return null;
            }
        } finally {
            log.popDiagnosticHandler(discardDiagnosticHandler);
        }
    }

    /**
     * Finds the field with name {@code name} in a given type.
     *
     * <p>The method adheres to all the rules of Java's scoping (while also considering the imports)
     * for name resolution.
     *
     * @param name the name of the field
     * @param type the type of the receiver (i.e., the type in which to look for the field).
     * @param path the tree path to the local scope
     * @return the element for the field, {@code null} otherwise
     */
    public @Nullable VariableElement findField(String name, TypeMirror type, TreePath path) {
        Log.DiagnosticHandler discardDiagnosticHandler = new Log.DiscardDiagnosticHandler(log);
        try {
            Env<AttrContext> env = getEnvForPath(path);
            Element res =
                    wrapInvocationOnResolveInstance(
                            FIND_IDENT_IN_TYPE,
                            env,
                            type,
                            names.fromString(name),
                            Kinds.KindSelector.VAR);

            if (res.getKind() == ElementKind.FIELD) {
                return (VariableElement) res;
            } else if (res.getKind() == ElementKind.OTHER && ACCESSERROR.isInstance(res)) {
                // Return the inaccessible field that was found
                return (VariableElement) wrapInvocation(res, ACCESSERROR_ACCESS, null, null);
            } else {
                // Most likely didn't find the field and the Element is a SymbolNotFoundError
                return null;
            }
        } finally {
            log.popDiagnosticHandler(discardDiagnosticHandler);
        }
    }

    /**
     * Finds the local variable with name {@code name} in the given scope.
     *
     * @param name the name of the local variable
     * @param path the tree path to the local scope
     * @return the element for the local variable, {@code null} otherwise
     */
    public @Nullable VariableElement findLocalVariableOrParameterOrField(
            String name, TreePath path) {
        Log.DiagnosticHandler discardDiagnosticHandler = new Log.DiscardDiagnosticHandler(log);
        try {
            Env<AttrContext> env = getEnvForPath(path);
            Element res = wrapInvocationOnResolveInstance(FIND_VAR, env, names.fromString(name));
            if (res.getKind() == ElementKind.LOCAL_VARIABLE
                    || res.getKind() == ElementKind.PARAMETER
                    || res.getKind() == ElementKind.FIELD) {
                return (VariableElement) res;
            } else {
                // Most likely didn't find the variable and the Element is a SymbolNotFoundError
                return null;
            }
        } finally {
            log.popDiagnosticHandler(discardDiagnosticHandler);
        }
    }

    /**
     * Finds the class literal with name {@code name}.
     *
     * <p>The method adheres to all the rules of Java's scoping (while also considering the imports)
     * for name resolution.
     *
     * @param name the name of the class
     * @param path the tree path to the local scope
     * @return the element for the class
     */
    public Element findClass(String name, TreePath path) {
        Log.DiagnosticHandler discardDiagnosticHandler = new Log.DiscardDiagnosticHandler(log);
        try {
            Env<AttrContext> env = getEnvForPath(path);
            return wrapInvocationOnResolveInstance(FIND_TYPE, env, names.fromString(name));
        } finally {
            log.popDiagnosticHandler(discardDiagnosticHandler);
        }
    }

    /**
     * Finds the class with name {@code name} in a given package.
     *
     * @param name the name of the class
     * @param pck the PackageSymbol for the package
     * @param path the tree path to the local scope
     * @return the {@code ClassSymbol} for the class if it is found, {@code null} otherwise
     */
    public @Nullable ClassSymbol findClassInPackage(String name, PackageSymbol pck, TreePath path) {
        Log.DiagnosticHandler discardDiagnosticHandler = new Log.DiscardDiagnosticHandler(log);
        try {
            Env<AttrContext> env = getEnvForPath(path);
            Element res =
                    wrapInvocationOnResolveInstance(
                            FIND_IDENT_IN_PACKAGE,
                            env,
                            pck,
                            names.fromString(name),
                            Kinds.KindSelector.TYP);
            if (res.getKind() == ElementKind.CLASS) {
                return (ClassSymbol) res;
            } else {
                return null;
            }
        } finally {
            log.popDiagnosticHandler(discardDiagnosticHandler);
        }
    }

    /**
     * Finds the method element for a given name and list of expected parameter types.
     *
     * <p>The method adheres to all the rules of Java's scoping (while also considering the imports)
     * for name resolution.
     *
     * @param methodName name of the method to find
     * @param receiverType type of the receiver of the method
     * @param path tree path
     * @return the method element (if found)
     */
    public Element findMethod(
            String methodName,
            TypeMirror receiverType,
            TreePath path,
            java.util.List<TypeMirror> argumentTypes) {
        Log.DiagnosticHandler discardDiagnosticHandler = new Log.DiscardDiagnosticHandler(log);
        try {
            Env<AttrContext> env = getEnvForPath(path);

            Type site = (Type) receiverType;
            Name name = names.fromString(methodName);
            List<Type> argtypes = List.nil();
            for (TypeMirror a : argumentTypes) {
                argtypes = argtypes.append((Type) a);
            }
            List<Type> typeargtypes = List.nil();
            boolean allowBoxing = true;
            boolean useVarargs = false;

            try {
                // For some reason we have to set our own method context, which is rather ugly.
                // TODO: find a nicer way to do this.
                Object methodContext = buildMethodContext();
                Object oldContext = getField(resolve, "currentResolutionContext");
                setField(resolve, "currentResolutionContext", methodContext);
                Element result =
                        wrapInvocationOnResolveInstance(
                                FIND_METHOD,
                                env,
                                site,
                                name,
                                argtypes,
                                typeargtypes,
                                allowBoxing,
                                useVarargs);
                setField(resolve, "currentResolutionContext", oldContext);
                return result;
            } catch (Throwable t) {
                Error err =
                        new AssertionError(
                                String.format(
                                        "Unexpected Reflection error in findMethod(%s, %s, ..., %s)",
                                        methodName,
                                        receiverType,
                                        // path
                                        argumentTypes));
                err.initCause(t);
                throw err;
            }
        } finally {
            log.popDiagnosticHandler(discardDiagnosticHandler);
        }
    }

    /** Build an instance of {@code Resolve$MethodResolutionContext}. */
    protected Object buildMethodContext()
            throws ClassNotFoundException, InstantiationException, IllegalAccessException,
                    InvocationTargetException, NoSuchFieldException {
        // Class is not accessible, instantiate reflectively.
        Class<?> methCtxClss =
                Class.forName("com.sun.tools.javac.comp.Resolve$MethodResolutionContext");
        Constructor<?> constructor = methCtxClss.getDeclaredConstructors()[0];
        constructor.setAccessible(true);
        Object methodContext = constructor.newInstance(resolve);
        // we need to also initialize the fields attrMode and step
        setField(methodContext, "attrMode", DeferredAttr.AttrMode.CHECK);
        @SuppressWarnings("rawtypes")
        List<?> phases = (List) getField(resolve, "methodResolutionSteps");
        assert phases != null : "@AssumeAssertion(nullness): assumption";
        setField(methodContext, "step", phases.get(1));
        return methodContext;
    }

    /** Reflectively set a field. */
    private void setField(Object receiver, String fieldName, @Nullable Object value)
            throws NoSuchFieldException, IllegalAccessException {
        Field f = receiver.getClass().getDeclaredField(fieldName);
        f.setAccessible(true);
        f.set(receiver, value);
    }

    /** Reflectively get the value of a field. */
    private @Nullable Object getField(Object receiver, String fieldName)
            throws NoSuchFieldException, IllegalAccessException {
        Field f = receiver.getClass().getDeclaredField(fieldName);
        f.setAccessible(true);
        return f.get(receiver);
    }

    /** Wrap a method invocation on the {code resolve} object. */
    private Symbol wrapInvocationOnResolveInstance(Method method, Object... args) {
        return wrapInvocation(resolve, method, args);
    }

    /** Wrap a method invocation. */
    private Symbol wrapInvocation(Object receiver, Method method, @Nullable Object... args) {
        try {
            @SuppressWarnings("nullness") // assume arguments are OK
            @NonNull Symbol res = (Symbol) method.invoke(receiver, args);
            return res;
        } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
            throw new BugInCF(
                    String.format(
                            "Unexpected Reflection error in wrapInvocation(%s, %s, %s)",
                            receiver, method, args),
                    e);
        }
    }
}
