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

import static com.android.SdkConstants.CURRENT_PLATFORM;
import static com.android.SdkConstants.PLATFORM_WINDOWS;

import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Project;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.TextFormat;
import com.android.utils.XmlUtils;
import com.google.common.annotations.Beta;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXParseException;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;

/**
 * Default implementation of a {@link Configuration} which reads and writes
 * configuration data into {@code lint.xml} in the project directory.
 * <p>
 * <b>NOTE: This is not a public or final API; if you rely on this be prepared
 * to adjust your code for the next tools release.</b>
 */
@Beta
public class DefaultConfiguration extends Configuration {
    private final LintClient mClient;
    /** Default name of the configuration file */
    public static final String CONFIG_FILE_NAME = "lint.xml"; //$NON-NLS-1$

    // Lint XML File
    @NonNull
    private static final String TAG_ISSUE = "issue"; //$NON-NLS-1$
    @NonNull
    private static final String ATTR_ID = "id"; //$NON-NLS-1$
    @NonNull
    private static final String ATTR_SEVERITY = "severity"; //$NON-NLS-1$
    @NonNull
    private static final String ATTR_PATH = "path"; //$NON-NLS-1$
    @NonNull
    private static final String ATTR_REGEXP = "regexp"; //$NON-NLS-1$
    @NonNull
    private static final String TAG_IGNORE = "ignore"; //$NON-NLS-1$
    @NonNull
    private static final String VALUE_ALL = "all"; //$NON-NLS-1$

    private final Configuration mParent;
    private final Project mProject;
    private final File mConfigFile;
    private boolean mBulkEditing;

    /** Map from id to list of project-relative paths for suppressed warnings */
    private Map<String, List<String>> mSuppressed;

    /** Map from id to regular expressions. */
    @Nullable
    private Map<String, List<Pattern>> mRegexps;

    /**
     * Map from id to custom {@link Severity} override
     */
    private Map<String, Severity> mSeverity;

    protected DefaultConfiguration(
            @NonNull LintClient client,
            @Nullable Project project,
            @Nullable Configuration parent,
            @NonNull File configFile) {
        mClient = client;
        mProject = project;
        mParent = parent;
        mConfigFile = configFile;
    }

    protected DefaultConfiguration(
            @NonNull LintClient client,
            @NonNull Project project,
            @Nullable Configuration parent) {
        this(client, project, parent, new File(project.getDir(), CONFIG_FILE_NAME));
    }

    /**
     * Creates a new {@link DefaultConfiguration}
     *
     * @param client the client to report errors to etc
     * @param project the associated project
     * @param parent the parent/fallback configuration or null
     * @return a new configuration
     */
    @NonNull
    public static DefaultConfiguration create(
            @NonNull LintClient client,
            @NonNull Project project,
            @Nullable Configuration parent) {
        return new DefaultConfiguration(client, project, parent);
    }

    /**
     * Creates a new {@link DefaultConfiguration} for the given lint config
     * file, not affiliated with a project. This is used for global
     * configurations.
     *
     * @param client the client to report errors to etc
     * @param lintFile the lint file containing the configuration
     * @return a new configuration
     */
    @NonNull
    public static DefaultConfiguration create(@NonNull LintClient client, @NonNull File lintFile) {
        return new DefaultConfiguration(client, null /*project*/, null /*parent*/, lintFile);
    }

    @Override
    public boolean isIgnored(
            @NonNull Context context,
            @NonNull Issue issue,
            @Nullable Location location,
            @NonNull String message) {
        ensureInitialized();

        String id = issue.getId();
        List<String> paths = mSuppressed.get(id);
        if (paths == null) {
            paths = mSuppressed.get(VALUE_ALL);
        }
        if (paths != null && location != null) {
            File file = location.getFile();
            String relativePath = context.getProject().getRelativePath(file);
            for (String suppressedPath : paths) {
                if (suppressedPath.equals(relativePath)) {
                    return true;
                }
                // Also allow a prefix
                if (relativePath.startsWith(suppressedPath)) {
                    return true;
                }
            }
        }

        if (mRegexps != null) {
            List<Pattern> regexps = mRegexps.get(id);
            if (regexps == null) {
                regexps = mRegexps.get(VALUE_ALL);
            }
            if (regexps != null && location != null) {
                // Check message
                for (Pattern regexp : regexps) {
                    Matcher matcher = regexp.matcher(message);
                    if (matcher.find()) {
                        return true;
                    }
                }

                // Check location
                File file = location.getFile();
                String relativePath = context.getProject().getRelativePath(file);
                boolean checkUnixPath = false;
                for (Pattern regexp : regexps) {
                    Matcher matcher = regexp.matcher(relativePath);
                    if (matcher.find()) {
                        return true;
                    } else if (regexp.pattern().indexOf('/') != -1) {
                        checkUnixPath = true;
                    }
                }

                if (checkUnixPath && CURRENT_PLATFORM == PLATFORM_WINDOWS) {
                    relativePath = relativePath.replace('\\', '/');
                    for (Pattern regexp : regexps) {
                        Matcher matcher = regexp.matcher(relativePath);
                        if (matcher.find()) {
                            return true;
                        }
                    }
                }
            }
        }

        return mParent != null && mParent.isIgnored(context, issue, location, message);
    }

    @NonNull
    protected Severity getDefaultSeverity(@NonNull Issue issue) {
        if (!issue.isEnabledByDefault()) {
            return Severity.IGNORE;
        }

        return issue.getDefaultSeverity();
    }

    @Override
    @NonNull
    public Severity getSeverity(@NonNull Issue issue) {
        ensureInitialized();

        Severity severity = mSeverity.get(issue.getId());
        if (severity == null) {
            severity = mSeverity.get(VALUE_ALL);
        }

        if (severity != null) {
            return severity;
        }

        if (mParent != null) {
            return mParent.getSeverity(issue);
        }

        return getDefaultSeverity(issue);
    }

    private void ensureInitialized() {
        if (mSuppressed == null) {
            readConfig();
        }
    }

    private void formatError(String message, Object... args) {
        if (args != null && args.length > 0) {
            message = String.format(message, args);
        }
        message = "Failed to parse `lint.xml` configuration file: " + message;
        LintDriver driver = new LintDriver(new IssueRegistry() {
            @Override @NonNull public List<Issue> getIssues() {
                return Collections.emptyList();
            }
        }, mClient);
        mClient.report(new Context(driver, mProject, mProject, mConfigFile),
                IssueRegistry.LINT_ERROR,
                mProject.getConfiguration(driver).getSeverity(IssueRegistry.LINT_ERROR),
                Location.create(mConfigFile), message, TextFormat.RAW);
    }

    private void readConfig() {
        mSuppressed = new HashMap<String, List<String>>();
        mSeverity = new HashMap<String, Severity>();

        if (!mConfigFile.exists()) {
            return;
        }

        try {
            Document document = XmlUtils.parseUtfXmlFile(mConfigFile, false);
            NodeList issues = document.getElementsByTagName(TAG_ISSUE);
            Splitter splitter = Splitter.on(',').trimResults().omitEmptyStrings();
            for (int i = 0, count = issues.getLength(); i < count; i++) {
                Node node = issues.item(i);
                Element element = (Element) node;
                String idList = element.getAttribute(ATTR_ID);
                if (idList.isEmpty()) {
                    formatError("Invalid lint config file: Missing required issue id attribute");
                    continue;
                }
                Iterable<String> ids = splitter.split(idList);

                NamedNodeMap attributes = node.getAttributes();
                for (int j = 0, n = attributes.getLength(); j < n; j++) {
                    Node attribute = attributes.item(j);
                    String name = attribute.getNodeName();
                    String value = attribute.getNodeValue();
                    if (ATTR_ID.equals(name)) {
                        // already handled
                    } else if (ATTR_SEVERITY.equals(name)) {
                        for (Severity severity : Severity.values()) {
                            if (value.equalsIgnoreCase(severity.name())) {
                                for (String id : ids) {
                                    mSeverity.put(id, severity);
                                }
                                break;
                            }
                        }
                    } else {
                        formatError("Unexpected attribute \"%1$s\"", name);
                    }
                }

                // Look up ignored errors
                NodeList childNodes = element.getChildNodes();
                if (childNodes.getLength() > 0) {
                    for (int j = 0, n = childNodes.getLength(); j < n; j++) {
                        Node child = childNodes.item(j);
                        if (child.getNodeType() == Node.ELEMENT_NODE) {
                            Element ignore = (Element) child;
                            String path = ignore.getAttribute(ATTR_PATH);
                            if (path.isEmpty()) {
                                String regexp = ignore.getAttribute(ATTR_REGEXP);
                                if (regexp.isEmpty()) {
                                    formatError("Missing required attribute %1$s or %2$s under %3$s",
                                        ATTR_PATH, ATTR_REGEXP, idList);
                                } else {
                                    addRegexp(idList, ids, n, regexp, false);
                                }
                            } else {
                                // Normalize path format to File.separator. Also
                                // handle the file format containing / or \.
                                if (File.separatorChar == '/') {
                                    path = path.replace('\\', '/');
                                } else {
                                    path = path.replace('/', File.separatorChar);
                                }

                                if (path.indexOf('*') != -1) {
                                    String regexp = globToRegexp(path);
                                    addRegexp(idList, ids, n, regexp, false);
                                } else {
                                    for (String id : ids) {
                                        List<String> paths = mSuppressed.get(id);
                                        if (paths == null) {
                                            paths = new ArrayList<String>(n / 2 + 1);
                                            mSuppressed.put(id, paths);
                                        }
                                        paths.add(path);
                                    }
                                }
                            }
                        }
                    }
                }
            }
        } catch (SAXParseException e) {
            formatError(e.getMessage());
        } catch (Exception e) {
            mClient.log(e, null);
        }
    }

    @NonNull
    public static String globToRegexp(@NonNull String glob) {
        StringBuilder sb = new StringBuilder(glob.length() * 2);
        int begin = 0;
        sb.append('^');
        for (int i = 0, n = glob.length(); i < n; i++) {
            char c = glob.charAt(i);
            if (c == '*') {
                begin = appendQuoted(sb, glob, begin, i) + 1;
                if (i < n - 1 && glob.charAt(i + 1) == '*') {
                    i++;
                    begin++;
                }
                sb.append(".*?");
            } else if (c == '?') {
                begin = appendQuoted(sb, glob, begin, i) + 1;
                sb.append(".?");
            }
        }
        appendQuoted(sb, glob, begin, glob.length());
        sb.append('$');
        return sb.toString();
    }

    private static int appendQuoted(StringBuilder sb, String s, int from, int to) {
        if (to > from) {
            boolean isSimple = true;
            for (int i = from; i < to; i++) {
                char c = s.charAt(i);
                if (!Character.isLetterOrDigit(c) && c != '/' && c != ' ') {
                    isSimple = false;
                    break;
                }
            }
            if (isSimple) {
                for (int i = from; i < to; i++) {
                    sb.append(s.charAt(i));
                }
                return to;
            }
            sb.append(Pattern.quote(s.substring(from, to)));
        }
        return to;
    }

    private void addRegexp(@NonNull String idList, @NonNull Iterable<String> ids, int n,
            @NonNull String regexp, boolean silent) {
        try {
            if (mRegexps == null) {
                mRegexps = new HashMap<String, List<Pattern>>();
            }
            Pattern pattern = Pattern.compile(regexp);
            for (String id : ids) {
                List<Pattern> paths = mRegexps.get(id);
                if (paths == null) {
                    paths = new ArrayList<Pattern>(n / 2 + 1);
                    mRegexps.put(id, paths);
                }
                paths.add(pattern);
            }
        } catch (PatternSyntaxException e) {
            if (!silent) {
                formatError("Invalid pattern %1$s under %2$s: %3$s",
                        regexp, idList, e.getDescription());
            }
        }
    }

    private void writeConfig() {
        try {
            // Write the contents to a new file first such that we don't clobber the
            // existing file if some I/O error occurs.
            File file = new File(mConfigFile.getParentFile(),
                    mConfigFile.getName() + ".new"); //$NON-NLS-1$

            Writer writer = new BufferedWriter(new FileWriter(file));
            writer.write(
                    "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +     //$NON-NLS-1$
                    "<lint>\n");                                         //$NON-NLS-1$

            if (!mSuppressed.isEmpty() || !mSeverity.isEmpty()) {
                // Process the maps in a stable sorted order such that if the
                // files are checked into version control with the project,
                // there are no random diffs just because hashing algorithms
                // differ:
                Set<String> idSet = new HashSet<String>();
                for (String id : mSuppressed.keySet()) {
                    idSet.add(id);
                }
                for (String id : mSeverity.keySet()) {
                    idSet.add(id);
                }
                List<String> ids = new ArrayList<String>(idSet);
                Collections.sort(ids);

                for (String id : ids) {
                    writer.write("    <");                               //$NON-NLS-1$
                    writer.write(TAG_ISSUE);
                    writeAttribute(writer, ATTR_ID, id);
                    Severity severity = mSeverity.get(id);
                    if (severity != null) {
                        writeAttribute(writer, ATTR_SEVERITY,
                                severity.name().toLowerCase(Locale.US));
                    }

                    List<Pattern> regexps = mRegexps != null ? mRegexps.get(id) : null;
                    List<String> paths = mSuppressed.get(id);
                    if (paths != null && !paths.isEmpty()
                            || regexps != null && !regexps.isEmpty()) {
                        writer.write('>');
                        writer.write('\n');
                        // The paths are already kept in sorted order when they are modified
                        // by ignore(...)
                        if (paths != null) {
                            for (String path : paths) {
                                writer.write("        <");                   //$NON-NLS-1$
                                writer.write(TAG_IGNORE);
                                writeAttribute(writer, ATTR_PATH, path.replace('\\', '/'));
                                writer.write(" />\n");                       //$NON-NLS-1$
                            }
                        }
                        if (regexps != null) {
                            for (Pattern regexp : regexps) {
                                writer.write("        <");                   //$NON-NLS-1$
                                writer.write(TAG_IGNORE);
                                writeAttribute(writer, ATTR_REGEXP, regexp.pattern());
                                writer.write(" />\n");                       //$NON-NLS-1$
                            }
                        }
                        writer.write("    </");                          //$NON-NLS-1$
                        writer.write(TAG_ISSUE);
                        writer.write('>');
                        writer.write('\n');
                    } else {
                        writer.write(" />\n");                           //$NON-NLS-1$
                    }
                }
            }

            writer.write("</lint>");                                     //$NON-NLS-1$
            writer.close();

            // Move file into place: move current version to lint.xml~ (removing the old ~ file
            // if it exists), then move the new version to lint.xml.
            File oldFile = new File(mConfigFile.getParentFile(),
                    mConfigFile.getName() + '~'); //$NON-NLS-1$
            if (oldFile.exists()) {
                oldFile.delete();
            }
            if (mConfigFile.exists()) {
                mConfigFile.renameTo(oldFile);
            }
            boolean ok = file.renameTo(mConfigFile);
            if (ok && oldFile.exists()) {
                oldFile.delete();
            }
        } catch (Exception e) {
            mClient.log(e, null);
        }
    }

    private static void writeAttribute(
            @NonNull Writer writer, @NonNull String name, @NonNull String value)
            throws IOException {
        writer.write(' ');
        writer.write(name);
        writer.write('=');
        writer.write('"');
        writer.write(value);
        writer.write('"');
    }

    @Override
    public void ignore(
            @NonNull Context context,
            @NonNull Issue issue,
            @Nullable Location location,
            @NonNull String message) {
        // This configuration only supports suppressing warnings on a per-file basis
        if (location != null) {
            ignore(issue, location.getFile());
        }
    }

    /**
     * Marks the given issue and file combination as being ignored.
     *
     * @param issue the issue to be ignored in the given file
     * @param file the file to ignore the issue in
     */
    public void ignore(@NonNull Issue issue, @NonNull File file) {
        ensureInitialized();

        String path = mProject != null ? mProject.getRelativePath(file) : file.getPath();

        List<String> paths = mSuppressed.get(issue.getId());
        if (paths == null) {
            paths = new ArrayList<String>();
            mSuppressed.put(issue.getId(), paths);
        }
        paths.add(path);

        // Keep paths sorted alphabetically; makes XML output stable
        Collections.sort(paths);

        if (!mBulkEditing) {
            writeConfig();
        }
    }

    @Override
    public void setSeverity(@NonNull Issue issue, @Nullable Severity severity) {
        ensureInitialized();

        String id = issue.getId();
        if (severity == null) {
            mSeverity.remove(id);
        } else {
            mSeverity.put(id, severity);
        }

        if (!mBulkEditing) {
            writeConfig();
        }
    }

    @Override
    public void startBulkEditing() {
        mBulkEditing = true;
    }

    @Override
    public void finishBulkEditing() {
        mBulkEditing = false;
        writeConfig();
    }

    @VisibleForTesting
    File getConfigFile() {
        return mConfigFile;
    }
}