/*
 * 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_NS_NAME_PREFIX;
import static com.android.SdkConstants.ANDROID_STYLE_RESOURCE_PREFIX;
import static com.android.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.ATTR_LAYOUT;
import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT;
import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH;
import static com.android.SdkConstants.ATTR_NAME;
import static com.android.SdkConstants.ATTR_PARENT;
import static com.android.SdkConstants.ATTR_STYLE;
import static com.android.SdkConstants.AUTO_URI;
import static com.android.SdkConstants.FD_RES_LAYOUT;
import static com.android.SdkConstants.FQCN_GRID_LAYOUT_V7;
import static com.android.SdkConstants.GRID_LAYOUT;
import static com.android.SdkConstants.LAYOUT_RESOURCE_PREFIX;
import static com.android.SdkConstants.REQUEST_FOCUS;
import static com.android.SdkConstants.STYLE_RESOURCE_PREFIX;
import static com.android.SdkConstants.TABLE_LAYOUT;
import static com.android.SdkConstants.TABLE_ROW;
import static com.android.SdkConstants.TAG_DATA;
import static com.android.SdkConstants.TAG_IMPORT;
import static com.android.SdkConstants.TAG_ITEM;
import static com.android.SdkConstants.TAG_LAYOUT;
import static com.android.SdkConstants.TAG_STYLE;
import static com.android.SdkConstants.TAG_VARIABLE;
import static com.android.SdkConstants.VIEW_INCLUDE;
import static com.android.SdkConstants.VIEW_MERGE;
import static com.android.resources.ResourceFolderType.LAYOUT;
import static com.android.resources.ResourceFolderType.VALUES;
import static com.android.tools.lint.detector.api.LintUtils.getLayoutName;
import static com.android.tools.lint.detector.api.LintUtils.isNullLiteral;

import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.annotations.VisibleForTesting;
import com.android.ide.common.resources.ResourceUrl;
import com.android.resources.ResourceFolderType;
import com.android.resources.ResourceType;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Context;
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.ResourceEvaluator;
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.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.intellij.psi.JavaElementVisitor;
import com.intellij.psi.PsiExpression;
import com.intellij.psi.PsiMethod;
import com.intellij.psi.PsiMethodCallExpression;

import org.w3c.dom.Element;
import org.w3c.dom.Node;

import java.io.File;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Ensures that layout width and height attributes are specified
 */
public class RequiredAttributeDetector extends LayoutDetector implements JavaPsiScanner {
    /** The main issue discovered by this detector */
    public static final Issue ISSUE = Issue.create(
            "RequiredSize", //$NON-NLS-1$
            "Missing `layout_width` or `layout_height` attributes",

            "All views must specify an explicit `layout_width` and `layout_height` attribute. " +
            "There is a runtime check for this, so if you fail to specify a size, an exception " +
            "is thrown at runtime.\n" +
            "\n" +
            "It's possible to specify these widths via styles as well. GridLayout, as a special " +
            "case, does not require you to specify a size.",
            Category.CORRECTNESS,
            4,
            Severity.ERROR,
            new Implementation(
                    RequiredAttributeDetector.class,
                    EnumSet.of(Scope.JAVA_FILE, Scope.ALL_RESOURCE_FILES)));

    public static final String PERCENT_RELATIVE_LAYOUT
            = "android.support.percent.PercentRelativeLayout";
    public static final String ATTR_LAYOUT_WIDTH_PERCENT = "layout_widthPercent";
    public static final String ATTR_LAYOUT_HEIGHT_PERCENT = "layout_heightPercent";

    /** Map from each style name to parent style */
    @Nullable private Map<String, String> mStyleParents;

    /** Set of style names where the style sets the layout width */
    @Nullable private Set<String> mWidthStyles;

    /** Set of style names where the style sets the layout height */
    @Nullable private Set<String> mHeightStyles;

    /** Set of layout names for layouts that are included by an {@code <include>} tag
     * where the width is set on the include */
    @Nullable private Set<String> mIncludedWidths;

    /** Set of layout names for layouts that are included by an {@code <include>} tag
     * where the height is set on the include */
    @Nullable private Set<String> mIncludedHeights;

    /** Set of layout names for layouts that are included by an {@code <include>} tag
     * where the width is <b>not</b> set on the include */
    @Nullable private Set<String> mNotIncludedWidths;

    /** Set of layout names for layouts that are included by an {@code <include>} tag
     * where the height is <b>not</b> set on the include */
    @Nullable private Set<String> mNotIncludedHeights;

    /** Whether the width was set in a theme definition */
    private boolean mSetWidthInTheme;

    /** Whether the height was set in a theme definition */
    private boolean mSetHeightInTheme;

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

    @Override
    public boolean appliesTo(@NonNull ResourceFolderType folderType) {
        return folderType == LAYOUT || folderType == VALUES;
    }

    @Override
    public void afterCheckProject(@NonNull Context context) {
        // Process checks in two phases:
        // Phase 1: Gather styles and includes (styles are encountered after the layouts
        // so we can't do it in a single phase, and includes can be affected by includes from
        // layouts we haven't seen yet)
        // Phase 2: Process layouts, using gathered style and include data, and mark layouts
        // not known.
        //
        if (context.getPhase() == 1) {
            checkSizeSetInTheme();

            context.requestRepeat(this, Scope.RESOURCE_FILE_SCOPE);
        }
    }

    private boolean isWidthStyle(String style) {
        return isSizeStyle(style, mWidthStyles);
    }

    private boolean isHeightStyle(String style) {
        return isSizeStyle(style, mHeightStyles);
    }

    private boolean isSizeStyle(String style, Set<String> sizeStyles) {
        if (isFrameworkSizeStyle(style)) {
            return true;
        }
        if (sizeStyles == null) {
            return false;
        }
        return isSizeStyle(stripStylePrefix(style), sizeStyles, 0);
    }

    private static boolean isFrameworkSizeStyle(String style) {
        // The styles Widget.TextView.ListSeparator (and several theme variations, such as
        // Widget.Holo.TextView.ListSeparator, Widget.Holo.Light.TextView.ListSeparator, etc)
        // define layout_width and layout_height.
        // These are exposed through the listSeparatorTextViewStyle style.
        if (style.equals("?android:attr/listSeparatorTextViewStyle")      //$NON-NLS-1$
                || style.equals("?android/listSeparatorTextViewStyle")) { //$NON-NLS-1$
            return true;
        }

        // It's also set on Widget.QuickContactBadge and Widget.QuickContactBadgeSmall
        // These are exposed via a handful of attributes with a common prefix
        if (style.startsWith("?android:attr/quickContactBadgeStyle")) { //$NON-NLS-1$
            return true;
        }

        // Finally, the styles are set on MediaButton and Widget.Holo.Tab (and
        // Widget.Holo.Light.Tab) but these are not exposed via attributes.

        return false;
    }

    private boolean isSizeStyle(
            @NonNull String style,
            @NonNull Set<String> sizeStyles, int depth) {
        if (depth == 30) {
            // Cycle between local and framework attribute style missed
            // by the fact that we're stripping the distinction between framework
            // and local styles here
            return false;
        }

        assert !style.startsWith(STYLE_RESOURCE_PREFIX)
                && !style.startsWith(ANDROID_STYLE_RESOURCE_PREFIX);

        if (sizeStyles.contains(style)) {
            return true;
        }

        if (mStyleParents != null) {
            String parentStyle = mStyleParents.get(style);
            if (parentStyle != null) {
                parentStyle = stripStylePrefix(parentStyle);
                if (isSizeStyle(parentStyle, sizeStyles, depth + 1)) {
                    return true;
                }
            }
        }

        int index = style.lastIndexOf('.');
        if (index > 0) {
            return isSizeStyle(style.substring(0, index), sizeStyles, depth + 1);
        }

        return false;
    }

    private void checkSizeSetInTheme() {
        // Look through the styles and determine whether each style is a theme
        if (mStyleParents == null) {
            return;
        }

        Map<String, Boolean> isTheme = Maps.newHashMap();
        for (String style : mStyleParents.keySet()) {
            if (isTheme(stripStylePrefix(style), isTheme, 0)) {
                mSetWidthInTheme = true;
                mSetHeightInTheme = true;
                break;
            }
        }
    }

    private boolean isTheme(String style, Map<String, Boolean> isTheme, int depth) {
        if (depth == 30) {
            // Cycle between local and framework attribute style missed
            // by the fact that we're stripping the distinction between framework
            // and local styles here
            return false;
        }

        assert !style.startsWith(STYLE_RESOURCE_PREFIX)
                && !style.startsWith(ANDROID_STYLE_RESOURCE_PREFIX);

        Boolean known = isTheme.get(style);
        if (known != null) {
            return known;
        }

        if (style.contains("Theme")) { //$NON-NLS-1$
            isTheme.put(style, true);
            return true;
        }

        if (mStyleParents != null) {
            String parentStyle = mStyleParents.get(style);
            if (parentStyle != null) {
                parentStyle = stripStylePrefix(parentStyle);
                if (isTheme(parentStyle, isTheme, depth + 1)) {
                    isTheme.put(style, true);
                    return true;
                }
            }
        }

        int index = style.lastIndexOf('.');
        if (index > 0) {
            String parentStyle = style.substring(0, index);
            boolean result = isTheme(parentStyle, isTheme, depth + 1);
            isTheme.put(style, result);
            return result;
        }

        return false;
    }

    @VisibleForTesting
    static boolean hasLayoutVariations(File file) {
        File parent = file.getParentFile();
        if (parent == null) {
            return false;
        }
        File res = parent.getParentFile();
        if (res == null) {
            return false;
        }
        String name = file.getName();
        File[] folders = res.listFiles();
        if (folders == null) {
            return false;
        }
        for (File folder : folders) {
            if (!folder.getName().startsWith(FD_RES_LAYOUT)) {
                continue;
            }
            if (folder.equals(parent)) {
                continue;
            }
            File other = new File(folder, name);
            if (other.exists()) {
                return true;
            }
        }

        return false;
    }

    private static String stripStylePrefix(@NonNull String style) {
        if (style.startsWith(STYLE_RESOURCE_PREFIX)) {
            style = style.substring(STYLE_RESOURCE_PREFIX.length());
        } else if (style.startsWith(ANDROID_STYLE_RESOURCE_PREFIX)) {
            style = style.substring(ANDROID_STYLE_RESOURCE_PREFIX.length());
        }

        return style;
    }

    private static boolean isRootElement(@NonNull Node node) {
        return node == node.getOwnerDocument().getDocumentElement();
    }

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

    @Override
    public Collection<String> getApplicableElements() {
        return ALL;
    }

    @Override
    public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
        ResourceFolderType folderType = context.getResourceFolderType();
        int phase = context.getPhase();
        if (phase == 1 && folderType == VALUES) {
            String tag = element.getTagName();
            if (TAG_STYLE.equals(tag)) {
                String parent = element.getAttribute(ATTR_PARENT);
                if (parent != null && !parent.isEmpty()) {
                    String name = element.getAttribute(ATTR_NAME);
                    if (name != null && !name.isEmpty()) {
                        if (mStyleParents == null) {
                            mStyleParents = Maps.newHashMap();
                        }
                        mStyleParents.put(name, parent);
                    }
                }
            } else if (TAG_ITEM.equals(tag)
                    && TAG_STYLE.equals(element.getParentNode().getNodeName())) {
                String name = element.getAttribute(ATTR_NAME);
                if (name.endsWith(ATTR_LAYOUT_WIDTH) &&
                        name.equals(ANDROID_NS_NAME_PREFIX + ATTR_LAYOUT_WIDTH)) {
                    if (mWidthStyles == null) {
                        mWidthStyles = Sets.newHashSet();
                    }
                    String styleName = ((Element) element.getParentNode()).getAttribute(ATTR_NAME);
                    mWidthStyles.add(styleName);
                }
                if (name.endsWith(ATTR_LAYOUT_HEIGHT) &&
                        name.equals(ANDROID_NS_NAME_PREFIX + ATTR_LAYOUT_HEIGHT)) {
                    if (mHeightStyles == null) {
                        mHeightStyles = Sets.newHashSet();
                    }
                    String styleName = ((Element) element.getParentNode()).getAttribute(ATTR_NAME);
                    mHeightStyles.add(styleName);
                }
            }
        } else if (folderType == LAYOUT) {
            if (phase == 1) {
                // Gather includes
                if (element.getTagName().equals(VIEW_INCLUDE)) {
                    String layout = element.getAttribute(ATTR_LAYOUT);
                    if (layout != null && !layout.isEmpty()) {
                        recordIncludeWidth(layout,
                                element.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH));
                        recordIncludeHeight(layout,
                                element.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT));
                    }
                }
            } else {
                assert phase == 2; // Check everything using style data and include data
                boolean hasWidth = element.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH);
                boolean hasHeight = element.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT);

                if (mSetWidthInTheme) {
                    hasWidth = true;
                }

                if (mSetHeightInTheme) {
                    hasHeight = true;
                }

                if (hasWidth && hasHeight) {
                    return;
                }

                String tag = element.getTagName();
                if (VIEW_MERGE.equals(tag)
                        || VIEW_INCLUDE.equals(tag)
                        || REQUEST_FOCUS.equals(tag)) {
                    return;
                }

                // Data binding: these tags shouldn't specify width/height
                if (tag.equals(TAG_LAYOUT)
                        || tag.equals(TAG_VARIABLE)
                        || tag.equals(TAG_DATA)
                        || tag.equals(TAG_IMPORT)) {
                    return;
                }

                String parentTag = element.getParentNode() != null
                        ?  element.getParentNode().getNodeName() : "";
                if (TABLE_LAYOUT.equals(parentTag)
                        || TABLE_ROW.equals(parentTag)
                        || GRID_LAYOUT.equals(parentTag)
                        || FQCN_GRID_LAYOUT_V7.equals(parentTag)) {
                    return;
                }

                // PercentRelativeLayout or PercentFrameLayout?
                boolean isPercent = parentTag.startsWith("android.support.percent.Percent");
                if (isPercent) {
                    hasWidth |= element.hasAttributeNS(AUTO_URI, ATTR_LAYOUT_WIDTH_PERCENT);
                    hasHeight |= element.hasAttributeNS(AUTO_URI, ATTR_LAYOUT_HEIGHT_PERCENT);
                    if (hasWidth && hasHeight) {
                        return;
                    }
                }

                if (!context.getProject().getReportIssues()) {
                    // If this is a library project not being analyzed, ignore it
                    return;
                }

                boolean certain = true;
                boolean isRoot = isRootElement(element);
                if (isRoot || isRootElement(element.getParentNode())
                        && VIEW_MERGE.equals(parentTag)) {
                    String name = LAYOUT_RESOURCE_PREFIX + getLayoutName(context.file);
                    if (!hasWidth && mIncludedWidths != null) {
                        hasWidth = mIncludedWidths.contains(name);
                        // If the layout is *also* included in a context where the width
                        // was not set, we're not certain; it's possible that
                        if (mNotIncludedWidths != null && mNotIncludedWidths.contains(name)) {
                            hasWidth = false;
                            // If we only have a single layout we know that this layout isn't
                            // always included with layout_width or layout_height set, but
                            // if there are multiple layouts, it's possible that at runtime
                            // we only load the size-less layout by the tag which includes
                            // the size
                            certain = !hasLayoutVariations(context.file);
                        }
                    }
                    if (!hasHeight && mIncludedHeights != null) {
                        hasHeight = mIncludedHeights.contains(name);
                        if (mNotIncludedHeights != null && mNotIncludedHeights.contains(name)) {
                            hasHeight = false;
                            certain = !hasLayoutVariations(context.file);
                        }
                    }
                    if (hasWidth && hasHeight) {
                        return;
                    }
                }

                if (!hasWidth || !hasHeight) {
                    String style = element.getAttribute(ATTR_STYLE);
                    if (style != null && !style.isEmpty()) {
                        if (!hasWidth) {
                            hasWidth = isWidthStyle(style);
                        }
                        if (!hasHeight) {
                            hasHeight = isHeightStyle(style);
                        }
                    }
                    if (hasWidth && hasHeight) {
                        return;
                    }
                }

                String message;
                if (!(hasWidth || hasHeight)) {
                    if (certain) {
                        message = "The required `layout_width` and `layout_height` attributes " +
                                "are missing";
                    } else {
                        message = "The required `layout_width` and `layout_height` attributes " +
                                "*may* be missing";
                    }
                } else {
                    String attribute = hasWidth ? ATTR_LAYOUT_HEIGHT : ATTR_LAYOUT_WIDTH;
                    if (certain) {
                        message = String.format("The required `%1$s` attribute is missing",
                                attribute);
                    } else {
                        message = String.format("The required `%1$s` attribute *may* be missing",
                                attribute);
                    }
                }
                if (isPercent) {
                    String escapedLayoutWidth = '`' + ATTR_LAYOUT_WIDTH + '`';
                    String escapedLayoutHeight = '`' + ATTR_LAYOUT_HEIGHT + '`';
                    String escapedLayoutWidthPercent = '`' + ATTR_LAYOUT_WIDTH_PERCENT + '`';
                    String escapedLayoutHeightPercent = '`' + ATTR_LAYOUT_HEIGHT_PERCENT + '`';
                    message = message.replace(escapedLayoutWidth, escapedLayoutWidth + " or "
                            + escapedLayoutWidthPercent).replace(escapedLayoutHeight,
                            escapedLayoutHeight + " or " + escapedLayoutHeightPercent);
                }
                context.report(ISSUE, element, context.getLocation(element),
                        message);
            }
        }
    }

    private void recordIncludeWidth(String layout, boolean providesWidth) {
        if (providesWidth) {
            if (mIncludedWidths == null) {
                mIncludedWidths = Sets.newHashSet();
            }
            mIncludedWidths.add(layout);
        } else {
            if (mNotIncludedWidths == null) {
                mNotIncludedWidths = Sets.newHashSet();
            }
            mNotIncludedWidths.add(layout);
        }
    }

    private void recordIncludeHeight(String layout, boolean providesHeight) {
        if (providesHeight) {
            if (mIncludedHeights == null) {
                mIncludedHeights = Sets.newHashSet();
            }
            mIncludedHeights.add(layout);
        } else {
            if (mNotIncludedHeights == null) {
                mNotIncludedHeights = Sets.newHashSet();
            }
            mNotIncludedHeights.add(layout);
        }
    }

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

    @Override
    @Nullable
    public List<String> getApplicableMethodNames() {
        return Collections.singletonList("inflate"); //$NON-NLS-1$
    }

    @Override
    public void visitMethod(@NonNull JavaContext context, @Nullable JavaElementVisitor visitor,
            @NonNull PsiMethodCallExpression call, @NonNull PsiMethod method) {
        // Handle
        //    View#inflate(Context context, int resource, ViewGroup root)
        //    LayoutInflater#inflate(int resource, ViewGroup root)
        //    LayoutInflater#inflate(int resource, ViewGroup root, boolean attachToRoot)
        PsiExpression[] args = call.getArgumentList().getExpressions();

        String layout = null;
        int index = 0;
        ResourceEvaluator evaluator = new ResourceEvaluator(context.getEvaluator());
        for (PsiExpression expression : args) {
            ResourceUrl url = evaluator.getResource(expression);
            if (url != null && url.type == ResourceType.LAYOUT) {
                layout = url.toString();
                break;
            }
            index++;
        }

        if (layout == null) {
            // Flow analysis didn't succeed
            return;
        }

        // In all the applicable signatures, the view root argument is immediately after
        // the layout resource id.
        int viewRootPos = index + 1;
        if (viewRootPos < args.length) {
            PsiExpression viewRoot = args[viewRootPos];
            if (isNullLiteral(viewRoot)) {
                // Yep, this one inflates the given view with a null parent:
                // Tag it as such. For now just use the include data structure since
                // it has the same net effect
                recordIncludeWidth(layout, true);
                recordIncludeHeight(layout, true);
            }
        }
    }
}
