/*
 * 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.ATTR_NAME;
import static com.android.SdkConstants.TAG_ARRAY;
import static com.android.SdkConstants.TAG_INTEGER_ARRAY;
import static com.android.SdkConstants.TAG_STRING_ARRAY;

import com.android.annotations.NonNull;
import com.android.ide.common.rendering.api.ArrayResourceValue;
import com.android.ide.common.rendering.api.ResourceValue;
import com.android.ide.common.res2.AbstractResourceRepository;
import com.android.ide.common.res2.ResourceFile;
import com.android.ide.common.res2.ResourceItem;
import com.android.resources.ResourceFolderType;
import com.android.resources.ResourceType;
import com.android.tools.lint.client.api.LintClient;
import com.android.tools.lint.client.api.LintDriver;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
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.ResourceXmlDetector;
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.Pair;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Multimap;

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

import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Checks for arrays with inconsistent item counts
 */
public class ArraySizeDetector extends ResourceXmlDetector {

    /** Are there differences in how many array elements are declared? */
    public static final Issue INCONSISTENT = Issue.create(
            "InconsistentArrays", //$NON-NLS-1$
            "Inconsistencies in array element counts",
            "When an array is translated in a different locale, it should normally have " +
            "the same number of elements as the original array. When adding or removing " +
            "elements to an array, it is easy to forget to update all the locales, and this " +
            "lint warning finds inconsistencies like these.\n" +
            "\n" +
            "Note however that there may be cases where you really want to declare a " +
            "different number of array items in each configuration (for example where " +
            "the array represents available options, and those options differ for " +
            "different layout orientations and so on), so use your own judgement to " +
            "decide if this is really an error.\n" +
            "\n" +
            "You can suppress this error type if it finds false errors in your project.",
            Category.CORRECTNESS,
            7,
            Severity.WARNING,
            new Implementation(
                    ArraySizeDetector.class,
                    Scope.RESOURCE_FILE_SCOPE));

    private Multimap<File, Pair<String, Integer>> mFileToArrayCount;

    /** Locations for each array name. Populated during phase 2, if necessary */
    private Map<String, Location> mLocations;

    /** Error messages for each array name. Populated during phase 2, if necessary */
    private Map<String, String> mDescriptions;

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

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

    @Override
    public Collection<String> getApplicableElements() {
        return Arrays.asList(
                TAG_ARRAY,
                TAG_STRING_ARRAY,
                TAG_INTEGER_ARRAY
        );
    }

    @Override
    public void beforeCheckProject(@NonNull Context context) {
        if (context.getPhase() == 1) {
            mFileToArrayCount = ArrayListMultimap.create(30, 5);
        }
    }

    @Override
    public void afterCheckProject(@NonNull Context context) {
        if (context.getPhase() == 1) {
            boolean haveAllResources = context.getScope().contains(Scope.ALL_RESOURCE_FILES);
            if (!haveAllResources) {
                return;
            }

            // Check that all arrays for the same name have the same number of translations

            Set<String> alreadyReported = new HashSet<String>();
            Map<String, Integer> countMap = new HashMap<String, Integer>();
            Map<String, File> fileMap = new HashMap<String, File>();

            // Process the file in sorted file order to ensure stable output
            List<File> keys = new ArrayList<File>(mFileToArrayCount.keySet());
            Collections.sort(keys);

            for (File file : keys) {
                Collection<Pair<String, Integer>> pairs = mFileToArrayCount.get(file);
                for (Pair<String, Integer> pair : pairs) {
                    String name = pair.getFirst();

                    if (alreadyReported.contains(name)) {
                        continue;
                    }
                    Integer count = pair.getSecond();

                    Integer current = countMap.get(name);
                    if (current == null) {
                        countMap.put(name, count);
                        fileMap.put(name, file);
                    } else if (!count.equals(current)) {
                        alreadyReported.add(name);

                        if (mLocations == null) {
                            mLocations = new HashMap<String, Location>();
                            mDescriptions = new HashMap<String, String>();
                        }
                        mLocations.put(name, null);

                        String thisName = file.getParentFile().getName() + File.separator
                                + file.getName();
                        File otherFile = fileMap.get(name);
                        String otherName = otherFile.getParentFile().getName() + File.separator
                                + otherFile.getName();
                        String message = String.format(
                             "Array `%1$s` has an inconsistent number of items (%2$d in `%3$s`, %4$d in `%5$s`)",
                             name, count, thisName, current, otherName);
                         mDescriptions.put(name,  message);
                    }
                }
            }

            //noinspection VariableNotUsedInsideIf
            if (mLocations != null) {
                // Request another scan through the resources such that we can
                // gather the actual locations
                context.getDriver().requestRepeat(this, Scope.ALL_RESOURCES_SCOPE);
            }
            mFileToArrayCount = null;
        } else {
            if (mLocations != null) {
                List<String> names = new ArrayList<String>(mLocations.keySet());
                Collections.sort(names);
                for (String name : names) {
                    Location location = mLocations.get(name);
                    if (location == null) {
                        // Suppressed; see visitElement
                        continue;
                    }
                    // We were prepending locations, but we want to prefer the base folders
                    location = Location.reverse(location);

                    // Make sure we still have a conflict, in case one or more of the
                    // elements were marked with tools:ignore
                    int count = -1;
                    LintDriver driver = context.getDriver();
                    boolean foundConflict = false;
                    Location curr;
                    for (curr = location; curr != null; curr = curr.getSecondary()) {
                        Object clientData = curr.getClientData();
                        if (clientData instanceof Node) {
                            Node node = (Node) clientData;
                            if (driver.isSuppressed(null, INCONSISTENT, node)) {
                                continue;
                            }
                            int newCount = LintUtils.getChildCount(node);
                            if (newCount != count) {
                                if (count == -1) {
                                    count = newCount; // first number encountered
                                } else {
                                    foundConflict = true;
                                    break;
                                }
                            }
                        } else {
                            foundConflict = true;
                            break;
                        }
                    }

                    // Through one or more tools:ignore, there is no more conflict so
                    // ignore this element
                    if (!foundConflict) {
                        continue;
                    }

                    String message = mDescriptions.get(name);
                    context.report(INCONSISTENT, location, message);
                }
            }

            mLocations = null;
            mDescriptions = null;
        }
    }

    @Override
    public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
        int phase = context.getPhase();

        Attr attribute = element.getAttributeNode(ATTR_NAME);
        if (attribute == null || attribute.getValue().isEmpty()) {
            if (phase != 1) {
                return;
            }
            context.report(INCONSISTENT, element, context.getLocation(element),
                String.format("Missing name attribute in `%1$s` declaration",
                        element.getTagName()));
        } else {
            String name = attribute.getValue();
            if (phase == 1) {
                if (context.getProject().getReportIssues()) {
                    int childCount = LintUtils.getChildCount(element);

                    if (!context.getScope().contains(Scope.ALL_RESOURCE_FILES) &&
                            context.getClient().supportsProjectResources()) {
                        incrementalCheckCount(context, element, name, childCount);
                        return;
                    }

                    mFileToArrayCount.put(context.file, Pair.of(name, childCount));
                }
            } else {
                assert phase == 2;
                if (mLocations.containsKey(name)) {
                    if (context.getDriver().isSuppressed(context, INCONSISTENT, element)) {
                        return;
                    }
                    Location location = context.getLocation(element);
                    location.setClientData(element);
                    location.setMessage(String.format("Declaration with array size (%1$d)",
                                    LintUtils.getChildCount(element)));
                    location.setSecondary(mLocations.get(name));
                    mLocations.put(name, location);
                }
            }
        }
    }

    private static void incrementalCheckCount(@NonNull XmlContext context, @NonNull Element element,
            @NonNull String name, int childCount) {
        LintClient client = context.getClient();
        Project project = context.getMainProject();
        AbstractResourceRepository resources = client.getResourceRepository(project, true, false);
        if (resources == null) {
            return;
        }
        List<ResourceItem> items = resources.getResourceItem(ResourceType.ARRAY, name);
        if (items != null) {
            for (ResourceItem item : items) {
                ResourceFile source = item.getSource();
                if (source != null && LintUtils.isSameResourceFile(context.file,
                                                                   source.getFile())) {
                    continue;
                }
                ResourceValue rv = item.getResourceValue(false);
                if (rv instanceof ArrayResourceValue) {
                    ArrayResourceValue arv = (ArrayResourceValue) rv;
                    if (childCount != arv.getElementCount()) {
                        String thisName = context.file.getParentFile().getName() + File.separator
                                + context.file.getName();
                        assert source != null;
                        File otherFile = source.getFile();
                        String otherName = otherFile.getParentFile().getName() + File.separator
                                + otherFile.getName();
                        String message = String.format(
                                "Array `%1$s` has an inconsistent number of items (%2$d in `%3$s`, %4$d in `%5$s`)",
                                name, childCount, thisName, arv.getElementCount(), otherName);

                        context.report(INCONSISTENT, element, context.getLocation(element),
                                message);
                    }
                }
            }
        }
    }
}
