/*
 * 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.checks;

import static com.android.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.ATTR_HINT;
import static com.android.SdkConstants.ATTR_ID;
import static com.android.SdkConstants.ATTR_INPUT_METHOD;
import static com.android.SdkConstants.ATTR_INPUT_TYPE;
import static com.android.SdkConstants.ATTR_PASSWORD;
import static com.android.SdkConstants.ATTR_PHONE_NUMBER;
import static com.android.SdkConstants.ATTR_STYLE;
import static com.android.SdkConstants.EDIT_TEXT;
import static com.android.SdkConstants.ID_PREFIX;
import static com.android.SdkConstants.NEW_ID_PREFIX;

import com.android.annotations.NonNull;
import com.android.annotations.VisibleForTesting;
import com.android.ide.common.rendering.api.ResourceValue;
import com.android.tools.lint.client.api.LintClient;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.LayoutDetector;
import com.android.tools.lint.detector.api.LintUtils;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Project;
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 org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.Node;

import java.util.Collection;
import java.util.Collections;
import java.util.List;

/**
 * Checks for usability problems in text fields: omitting inputType, or omitting a hint.
 */
public class TextFieldDetector extends LayoutDetector {
    /** The main issue discovered by this detector */
    public static final Issue ISSUE = Issue.create(
            "TextFields", //$NON-NLS-1$
            "Missing `inputType` or `hint`",

            "Providing an `inputType` attribute on a text field improves usability " +
            "because depending on the data to be input, optimized keyboards can be shown " +
            "to the user (such as just digits and parentheses for a phone number). Similarly," +
            "a hint attribute displays a hint to the user for what is expected in the " +
            "text field.\n" +
            "\n" +
            "The lint detector also looks at the `id` of the view, and if the id offers a " +
            "hint of the purpose of the field (for example, the `id` contains the phrase " +
            "`phone` or `email`), then lint will also ensure that the `inputType` contains " +
            "the corresponding type attributes.\n" +
            "\n" +
            "If you really want to keep the text field generic, you can suppress this warning " +
            "by setting `inputType=\"text\"`.",

            Category.USABILITY,
            5,
            Severity.WARNING,
            new Implementation(
                    TextFieldDetector.class,
                    Scope.RESOURCE_FILE_SCOPE));

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

    @Override
    public Collection<String> getApplicableElements() {
        return Collections.singletonList(EDIT_TEXT);
    }

    @Override
    public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
        Node inputTypeNode = element.getAttributeNodeNS(ANDROID_URI, ATTR_INPUT_TYPE);
        String inputType = "";
        if (inputTypeNode != null) {
            inputType = inputTypeNode.getNodeValue();
        }

        boolean haveHint = false;
        if (inputTypeNode == null) {
            haveHint = element.hasAttributeNS(ANDROID_URI, ATTR_HINT);
            String style = element.getAttribute(ATTR_STYLE);
            if (style != null && !style.isEmpty()) {
                LintClient client = context.getClient();
                if (client.supportsProjectResources()) {
                    Project project = context.getMainProject();
                    List<ResourceValue> styles = LintUtils.getStyleAttributes(project, client,
                            style, ANDROID_URI, ATTR_INPUT_TYPE);
                    if (styles != null && !styles.isEmpty()) {
                        ResourceValue value = styles.get(0);
                        inputType = value.getValue();
                        inputTypeNode = element;
                    } else if (!haveHint) {
                        styles = LintUtils.getStyleAttributes(project, client, style,
                                ANDROID_URI, ATTR_HINT);
                        if (styles != null && !styles.isEmpty()) {
                            haveHint = true;
                        }
                    }
                } else {
                    // The input type might be specified via a style. This will require
                    // us to track these (similar to what is done for the
                    // RequiredAttributeDetector to track layout_width and layout_height
                    // in style declarations). For now, simply ignore these elements
                    // to avoid producing false positives.
                    return;
                }
            }
        }

        if (inputTypeNode == null && !haveHint) {
            // Also make sure the EditText does not set an inputMethod in which case
            // an inputType might be provided from the input.
            if (element.hasAttributeNS(ANDROID_URI, ATTR_INPUT_METHOD)) {
                return;
            }

            context.report(ISSUE, element, context.getLocation(element),
                    "This text field does not specify an `inputType` or a `hint`");
            return;
        }

        assert inputType != null; // because inputTypeNode || !haveHint check + return above

        Attr idNode = element.getAttributeNodeNS(ANDROID_URI, ATTR_ID);
        if (idNode == null) {
            return;
        }
        String id = idNode.getValue();
        if (id.isEmpty()) {
            return;
        }
        if (id.startsWith("editText")) { //$NON-NLS-1$
            // Just the default label
            return;
        }

        // TODO: See if the name is just the default names (button1, editText1 etc)
        // and if so, do nothing
        // TODO: Unit test this

        if (containsWord(id, "phone", true, true)) {                 //$NON-NLS-1$
            if (!inputType.contains("phone")                         //$NON-NLS-1$
                    && element.getAttributeNodeNS(ANDROID_URI, ATTR_PHONE_NUMBER) == null) {
                String message = String.format("The view name (`%1$s`) suggests this is a phone "
                        + "number, but it does not include '`phone`' in the `inputType`", id);
                reportMismatch(context, idNode, inputTypeNode, message);
            }
            return;
        }

        if (containsWord(id, "width", false, true)
                || containsWord(id, "height", false, true)
                || containsWord(id, "size", false, true)
                || containsWord(id, "length", false, true)
                || containsWord(id, "weight", false, true)
                || containsWord(id, "number", false, true)) {
            if (!inputType.contains("number") && !inputType.contains("phone")) { //$NON-NLS-1$
                String message = String.format("The view name (`%1$s`) suggests this is a number, "
                        + "but it does not include a numeric `inputType` (such as '`numberSigned`')",
                        id);
                reportMismatch(context, idNode, inputTypeNode, message);
            }
            return;
        }

        if (containsWord(id, "password", true, true)) {   //$NON-NLS-1$
            if (!(inputType.contains("Password"))  //$NON-NLS-1$
                && element.getAttributeNodeNS(ANDROID_URI, ATTR_PASSWORD) == null) {
                String message = String.format("The view name (`%1$s`) suggests this is a password, "
                        + "but it does not include '`textPassword`' in the `inputType`", id);
                reportMismatch(context, idNode, inputTypeNode, message);
            }
            return;
        }

        if (containsWord(id, "email", true, true)) {                   //$NON-NLS-1$
            if (!inputType.contains("Email")) { //$NON-NLS-1$
                String message = String.format("The view name (`%1$s`) suggests this is an e-mail "
                        + "address, but it does not include '`textEmail`' in the `inputType`", id);
                reportMismatch(context, idNode, inputTypeNode, message);
            }
            return;
        }

        if (endsWith(id, "pin", false, true)) {    //$NON-NLS-1$
            if (!(inputType.contains("numberPassword"))  //$NON-NLS-1$
                && element.getAttributeNodeNS(ANDROID_URI, ATTR_PASSWORD) == null) {
                String message = String.format("The view name (`%1$s`) suggests this is a password, "
                        + "but it does not include '`numberPassword`' in the `inputType`", id);
                reportMismatch(context, idNode, inputTypeNode, message);
            }
            return;
        }

        if ((containsWord(id, "uri") || containsWord(id, "url"))
                && !inputType.contains("textUri")) {
            String message = String.format("The view name (`%1$s`) suggests this is a URI, "
                    + "but it does not include '`textUri`' in the `inputType`", id);
            reportMismatch(context, idNode, inputTypeNode, message);
        }

        if ((containsWord(id, "date"))            //$NON-NLS-1$
                && !inputType.contains("date")) { //$NON-NLS-1$
            String message = String.format("The view name (`%1$s`) suggests this is a date, "
                    + "but it does not include '`date`' or '`datetime`' in the `inputType`", id);
            reportMismatch(context, idNode, inputTypeNode, message);
        }
    }

    private static void reportMismatch(XmlContext context, Attr idNode, Node inputTypeNode,
            String message) {
        Location location;
        if (inputTypeNode != null) {
            location = context.getLocation(inputTypeNode);
            Location secondary = context.getLocation(idNode);
            secondary.setMessage("id defined here");
            location.setSecondary(secondary);
        } else {
            location = context.getLocation(idNode);
        }
        context.report(ISSUE, idNode.getOwnerElement(), location, message);
    }

    /** Returns true if the given sentence contains a given word */
    @VisibleForTesting
    static boolean containsWord(String sentence, String word) {
        return containsWord(sentence, word, false, false);
    }

    /**
     * Returns true if the given sentence contains a given word
     * @param sentence the full sentence to search within
     * @param word the word to look for
     * @param allowPrefix if true, allow a prefix match even if the next character
     *    is in the same word (same case or not an underscore)
     * @param allowSuffix if true, allow a suffix match even if the preceding character
     *    is in the same word (same case or not an underscore)
     * @return true if the word is contained in the sentence
     */
    @VisibleForTesting
    static boolean containsWord(String sentence, String word, boolean allowPrefix,
            boolean allowSuffix) {
        return indexOfWord(sentence, word, allowPrefix, allowSuffix) != -1;
    }

    /** Returns true if the given sentence <b>ends</b> with a given word */
    private static boolean endsWith(String sentence, String word, boolean allowPrefix,
            boolean allowSuffix) {
        int index = indexOfWord(sentence, word, allowPrefix, allowSuffix);

        return index != -1 && index == sentence.length() - word.length();
    }

    /**
     * Returns the index of the given word in the given sentence, if any. It will match
     * across cases, and ignore words that seem to be just a substring in the middle
     * of another word.
     *
     * @param sentence the full sentence to search within
     * @param word the word to look for
     * @param allowPrefix if true, allow a prefix match even if the next character
     *    is in the same word (same case or not an underscore)
     * @param allowSuffix if true, allow a suffix match even if the preceding character
     *    is in the same word (same case or not an underscore)
     * @return true if the word is contained in the sentence
     */
    private static int indexOfWord(String sentence, String word, boolean allowPrefix,
            boolean allowSuffix) {
        if (sentence.isEmpty()) {
            return -1;
        }
        int wordLength = word.length();
        if (wordLength > sentence.length()) {
            return -1;
        }

        char firstUpper = Character.toUpperCase(word.charAt(0));
        char firstLower = Character.toLowerCase(firstUpper);

        int start = 0;
        if (sentence.startsWith(NEW_ID_PREFIX)) {
            start += NEW_ID_PREFIX.length();
        } else if (sentence.startsWith(ID_PREFIX)) {
            start += ID_PREFIX.length();
        }

        for (int i = start, n = sentence.length(), m = n - (wordLength - 1); i < m; i++) {
            char c = sentence.charAt(i);
            if (c == firstUpper || c == firstLower) {
                if (sentence.regionMatches(true, i, word, 0, wordLength)) {
                    if (i <= start && allowPrefix) {
                        return i;
                    }
                    if (i == m - 1 && allowSuffix) {
                        return i;
                    }
                    if (i <= start || (sentence.charAt(i - 1) == '_')
                            || Character.isUpperCase(c)) {
                        if (i == m - 1) {
                            return i;
                        }
                        char after = sentence.charAt(i + wordLength);
                        if (after == '_' || Character.isUpperCase(after)) {
                            return i;
                        }
                    }
                }
            }
        }

        return -1;
    }
}
