/*
 * Copyright (C) 2012 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.checks;

import static com.android.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.ATTR_NAME;
import static com.android.SdkConstants.CLASS_ACTIVITY;
import static com.android.SdkConstants.CLASS_APPLICATION;
import static com.android.SdkConstants.CLASS_BROADCASTRECEIVER;
import static com.android.SdkConstants.CLASS_CONTENTPROVIDER;
import static com.android.SdkConstants.CLASS_SERVICE;
import static com.android.SdkConstants.TAG_ACTIVITY;
import static com.android.SdkConstants.TAG_APPLICATION;
import static com.android.SdkConstants.TAG_PROVIDER;
import static com.android.SdkConstants.TAG_RECEIVER;
import static com.android.SdkConstants.TAG_SERVICE;

import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.builder.model.AndroidProject;
import com.android.builder.model.ProductFlavorContainer;
import com.android.builder.model.SourceProviderContainer;
import com.android.tools.lint.client.api.JavaEvaluator;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Detector.JavaPsiScanner;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.JavaContext;
import com.android.tools.lint.detector.api.LayoutDetector;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.XmlContext;
import com.android.utils.SdkUtils;
import com.google.common.collect.Maps;
import com.intellij.psi.PsiClass;

import org.w3c.dom.Element;

import java.io.File;
import java.util.Arrays;
import java.util.Collection;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;

/**
 * Checks for missing manifest registrations for activities, services etc
 * and also makes sure that they are registered with the correct tag
 */
public class RegistrationDetector extends LayoutDetector implements JavaPsiScanner {
    /** Unregistered activities and services */
    public static final Issue ISSUE = Issue.create(
            "Registered", //$NON-NLS-1$
            "Class is not registered in the manifest",

            "Activities, services and content providers should be registered in the " +
            "`AndroidManifest.xml` file using `<activity>`, `<service>` and `<provider>` tags.\n" +
            "\n" +
            "If your activity is simply a parent class intended to be subclassed by other " +
            "\"real\" activities, make it an abstract class.",

            Category.CORRECTNESS,
            6,
            Severity.WARNING,
            new Implementation(
                    RegistrationDetector.class,
                    EnumSet.of(Scope.MANIFEST, Scope.JAVA_FILE)))
            .addMoreInfo(
            "http://developer.android.com/guide/topics/manifest/manifest-intro.html"); //$NON-NLS-1$

    protected Map<String, String> mManifestRegistrations;

    /** Constructs a new {@link RegistrationDetector} */
    public RegistrationDetector() {
    }

    // ---- Implements XmlScanner ----

    @Override
    public Collection<String> getApplicableElements() {
        return Arrays.asList(sTags);
    }

    @Override
    public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
        if (!element.hasAttributeNS(ANDROID_URI, ATTR_NAME)) {
            // For example, application appears in manifest and doesn't always have a name
            return;
        }
        String fqcn = getFqcn(context, element);
        String tag = element.getTagName();
        String frameworkClass = tagToClass(tag);
        if (frameworkClass != null) {
            String signature = fqcn;
            if (mManifestRegistrations == null) {
                mManifestRegistrations = Maps.newHashMap();
            }
            mManifestRegistrations.put(signature, frameworkClass);
            if (signature.indexOf('$') != -1) {
                signature = signature.replace('$', '.');
                mManifestRegistrations.put(signature, frameworkClass);
            }
        }
    }

    /**
     * Returns the fully qualified class name for a manifest entry element that
     * specifies a name attribute
     *
     * @param context the query context providing the project
     * @param element the element
     * @return the fully qualified class name
     */
    @NonNull
    private static String getFqcn(@NonNull XmlContext context, @NonNull Element element) {
        String className = element.getAttributeNS(ANDROID_URI, ATTR_NAME);
        if (className.startsWith(".")) { //$NON-NLS-1$
            return context.getProject().getPackage() + className;
        } else if (className.indexOf('.') == -1) {
            // According to the <activity> manifest element documentation, this is not
            // valid ( http://developer.android.com/guide/topics/manifest/activity-element.html )
            // but it appears in manifest files and appears to be supported by the runtime
            // so handle this in code as well:
            return context.getProject().getPackage() + '.' + className;
        } // else: the class name is already a fully qualified class name

        return className;
    }

    // ---- Implements JavaScanner ----

    @Nullable
    @Override
    public List<String> applicableSuperClasses() {
        return Arrays.asList(
                // Common super class for Activity, ContentProvider, Service, Application
                // (as well as some other classes not registered in the manifest, such as
                // Fragment and VoiceInteractionSession)
                "android.content.ComponentCallbacks2",
                CLASS_BROADCASTRECEIVER);
    }

    @Override
    public void checkClass(@NonNull JavaContext context, @NonNull PsiClass cls) {
        if (cls.getName() == null) {
            // anonymous class; can't be registered
            return;
        }

        JavaEvaluator evaluator = context.getEvaluator();
        if (evaluator.isAbstract(cls) || evaluator.isPrivate(cls)) {
            // Abstract classes do not need to be registered, and
            // private classes are clearly not intended to be registered
            return;
        }

        String rightTag = getTag(evaluator, cls);
        if (rightTag == null) {
            // some non-registered Context, such as a BackupAgent
            return;
        }
        String className = cls.getQualifiedName();
        if (className == null) {
            return;
        }
        if (mManifestRegistrations != null) {
            String framework = mManifestRegistrations.get(className);
            if (framework == null) {
                reportMissing(context, cls, className, rightTag);
            } else if (!evaluator.extendsClass(cls, framework, false)) {
                reportWrongTag(context, cls, rightTag, className, framework);
            }
        } else {
            reportMissing(context, cls, className, rightTag);
        }
    }

    private static void reportWrongTag(
            @NonNull JavaContext context,
            @NonNull PsiClass node,
            @NonNull String rightTag,
            @NonNull String className,
            @NonNull String framework) {
        String wrongTag = classToTag(framework);
        if (wrongTag == null) {
            return;
        }
        Location location = context.getNameLocation(node);
        String message = String.format("`%1$s` is %2$s but is registered "
                        + "in the manifest as %3$s", className, describeTag(rightTag),
                describeTag(wrongTag));
        context.report(ISSUE, node, location, message);
    }

    private static String describeTag(@NonNull String tag) {
        String article = tag.startsWith("a") ? "an" : "a"; // an for activity and application
        return String.format("%1$s `<%2$s>`", article, tag);
    }

    private static void reportMissing(
            @NonNull JavaContext context,
            @NonNull PsiClass node,
            @NonNull String className,
            @NonNull String tag) {
        if (tag.equals(TAG_RECEIVER)) {
            // Receivers can be registered in code; don't flag these.
            return;
        }

        // Don't flag activities registered in test source sets
        if (context.getProject().isGradleProject()) {
            AndroidProject model = context.getProject().getGradleProjectModel();
            if (model != null) {
                String javaSource = context.file.getPath();
                // Test source set?

                for (SourceProviderContainer extra : model.getDefaultConfig().getExtraSourceProviders()) {
                    String artifactName = extra.getArtifactName();
                    if (AndroidProject.ARTIFACT_ANDROID_TEST.equals(artifactName)) {
                        for (File file : extra.getSourceProvider().getJavaDirectories()) {
                            if (SdkUtils.startsWithIgnoreCase(javaSource, file.getPath())) {
                                return;
                            }
                        }
                    }
                }

                for (ProductFlavorContainer container : model.getProductFlavors()) {
                    for (SourceProviderContainer extra : container.getExtraSourceProviders()) {
                        String artifactName = extra.getArtifactName();
                        if (AndroidProject.ARTIFACT_ANDROID_TEST.equals(artifactName)) {
                            for (File file : extra.getSourceProvider().getJavaDirectories()) {
                                if (SdkUtils.startsWithIgnoreCase(javaSource, file.getPath())) {
                                    return;
                                }
                            }
                        }
                    }
                }
            }
        }

        Location location = context.getNameLocation(node);
        String message = String.format("The `<%1$s> %2$s` is not registered in the manifest",
                tag, className);
        context.report(ISSUE, node, location, message);
    }

    private static String getTag(@NonNull JavaEvaluator evaluator, @NonNull PsiClass cls) {
        String tag = null;
        for (String s : sClasses) {
            if (evaluator.extendsClass(cls, s, false)) {
                tag = classToTag(s);
                break;
            }
        }
        return tag;
    }

    /** The manifest tags we care about */
    private static final String[] sTags = new String[] {
        TAG_ACTIVITY,
        TAG_SERVICE,
        TAG_RECEIVER,
        TAG_PROVIDER,
        TAG_APPLICATION
        // Keep synchronized with {@link #sClasses}
    };

    /** The corresponding framework classes that the tags in {@link #sTags} should extend */
    private static final String[] sClasses = new String[] {
            CLASS_ACTIVITY,
            CLASS_SERVICE,
            CLASS_BROADCASTRECEIVER,
            CLASS_CONTENTPROVIDER,
            CLASS_APPLICATION
            // Keep synchronized with {@link #sTags}
    };

    /** Looks up the corresponding framework class a given manifest tag's class should extend */
    private static String tagToClass(String tag) {
        for (int i = 0, n = sTags.length; i < n; i++) {
            if (sTags[i].equals(tag)) {
                return sClasses[i];
            }
        }

        return null;
    }

    /** Looks up the tag a given framework class should be registered with */
    protected static String classToTag(String className) {
        for (int i = 0, n = sClasses.length; i < n; i++) {
            if (sClasses[i].equals(className)) {
                return sTags[i];
            }
        }

        return null;
    }
}
