/*
 * Copyright (C) 2015 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_HOST;
import static com.android.SdkConstants.ATTR_SCHEME;
import static com.android.SdkConstants.MANIFEST_PLACEHOLDER_PREFIX;
import static com.android.SdkConstants.MANIFEST_PLACEHOLDER_SUFFIX;
import static com.android.SdkConstants.UTF_8;
import static com.android.xml.AndroidManifest.ATTRIBUTE_NAME;
import static com.android.xml.AndroidManifest.NODE_ACTION;
import static com.android.xml.AndroidManifest.NODE_CATEGORY;
import static com.android.xml.AndroidManifest.NODE_DATA;
import static com.android.xml.AndroidManifest.NODE_INTENT;

import com.android.SdkConstants;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.annotations.VisibleForTesting;
import com.android.builder.model.Variant;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Detector;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
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.Lists;
import com.google.common.collect.Maps;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.JsonSyntaxException;

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

import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.UnknownHostException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

/**
 * Check if the App Link which needs auto verification is correctly set.
 */
public class AppLinksAutoVerifyDetector extends Detector implements Detector.XmlScanner {

    private static final Implementation IMPLEMENTATION = new Implementation(
            AppLinksAutoVerifyDetector.class, Scope.MANIFEST_SCOPE);

    public static final Issue ISSUE_ERROR = Issue.create(
            "AppLinksAutoVerifyError", //$NON-NLS-1$
            "App Links Auto Verification Failure",
            "Ensures that app links are correctly set and associated with website.",
            Category.CORRECTNESS, 5, Severity.ERROR, IMPLEMENTATION)
            .addMoreInfo("https://g.co/appindexing/applinks").setEnabledByDefault(false);

    public static final Issue ISSUE_WARNING = Issue.create(
            "AppLinksAutoVerifyWarning", //$NON-NLS-1$
            "Potential App Links Auto Verification Failure",
            "Ensures that app links are correctly set and associated with website.",
            Category.CORRECTNESS, 5, Severity.WARNING, IMPLEMENTATION)
            .addMoreInfo("https://g.co/appindexing/applinks").setEnabledByDefault(false);

    private static final String ATTRIBUTE_AUTO_VERIFY = "autoVerify";
    private static final String JSON_RELATIVE_PATH = "/.well-known/assetlinks.json";

    @VisibleForTesting
    static final int STATUS_HTTP_CONNECT_FAIL = -1;
    @VisibleForTesting
    static final int STATUS_MALFORMED_URL = -2;
    @VisibleForTesting
    static final int STATUS_UNKNOWN_HOST = -3;
    @VisibleForTesting
    static final int STATUS_NOT_FOUND = -4;
    @VisibleForTesting
    static final int STATUS_WRONG_JSON_SYNTAX = -5;
    @VisibleForTesting
    static final int STATUS_JSON_PARSE_FAIL = -6;
    @VisibleForTesting
    static final int STATUS_HTTP_OK = 200;

    /* Maps website host url to a future task which will send HTTP request to fetch the JSON file
     * and also return the status code during the fetching process. */
    private final Map<String, Future<HttpResult>> mFutures = Maps.newHashMap();

    /* Maps website host url to host attribute in AndroidManifest.xml. */
    private final Map<String, Attr> mJsonHost = Maps.newHashMap();

    @Override
    public void visitDocument(@NonNull XmlContext context, @NonNull Document document) {

        // This check sends http request. Only done in batch mode.
        if (!context.getScope().contains(Scope.ALL_JAVA_FILES)) {
            return;
        }

        if (document.getDocumentElement() != null) {
            List<Element> intents = getTags(document.getDocumentElement(), NODE_INTENT);
            if (!needAutoVerification(intents)) {
                return;
            }

            for (Element intent : intents) {
                boolean actionView = hasNamedSubTag(intent, NODE_ACTION,
                        "android.intent.action.VIEW");
                boolean browsableCategory = hasNamedSubTag(intent, NODE_CATEGORY,
                        "android.intent.category.BROWSABLE");
                if (!actionView || !browsableCategory) {
                    continue;
                }
                mJsonHost.putAll(getJsonUrl(context, intent));
            }
        }

        Map<String, HttpResult> results = getJsonFileAsync();

        String packageName = context.getProject().getPackage();
        for (Map.Entry<String, HttpResult> result : results.entrySet()) {
            if (result.getValue() == null) {
                continue;
            }
            Attr host = mJsonHost.get(result.getKey());
            if (host == null) {
                continue;
            }
            String jsonPath = result.getKey() + JSON_RELATIVE_PATH;
            switch (result.getValue().mStatus) {
                case STATUS_HTTP_OK:
                    List<String> packageNames = getPackageNameFromJson(
                            result.getValue().mJsonFile);
                    if (!packageNames.contains(packageName)) {
                        context.report(ISSUE_ERROR, host, context.getLocation(host), String.format(
                                "This host does not support app links to your app. Checks the Digital Asset Links JSON file: %s",
                                jsonPath));
                    }
                    break;
                case STATUS_HTTP_CONNECT_FAIL:
                    context.report(ISSUE_WARNING, host, context.getLocation(host),
                            String.format("Connection to Digital Asset Links JSON file %s fails",
                                    jsonPath));
                    break;
                case STATUS_MALFORMED_URL:
                    context.report(ISSUE_ERROR, host, context.getLocation(host), String.format(
                            "Malformed URL of Digital Asset Links JSON file: %s. An unknown protocol is specified",
                            jsonPath));
                    break;
                case STATUS_UNKNOWN_HOST:
                    context.report(ISSUE_WARNING, host, context.getLocation(host), String.format(
                            "Unknown host: %s. Check if the host exists, and check your network connection",
                            result.getKey()));
                    break;
                case STATUS_NOT_FOUND:
                    context.report(ISSUE_ERROR, host, context.getLocation(host), String.format(
                            "Digital Asset Links JSON file %s is not found on the host", jsonPath));
                    break;
                case STATUS_WRONG_JSON_SYNTAX:
                    context.report(ISSUE_ERROR, host, context.getLocation(host),
                            String.format("%s has incorrect JSON syntax", jsonPath));
                    break;
                case STATUS_JSON_PARSE_FAIL:
                    context.report(ISSUE_ERROR, host, context.getLocation(host),
                            String.format("Parsing JSON file %s fails", jsonPath));
                    break;
                default:
                    context.report(ISSUE_WARNING, host, context.getLocation(host), String.format(
                            "HTTP request for Digital Asset Links JSON file %1$s fails. HTTP response code: %2$s",
                            jsonPath, result.getValue().mStatus));
            }
        }
    }

    /**
     * Gets all the tag elements with a specific tag name, within a parent tag element.
     *
     * @param element The parent tag element.
     * @return List of tag elements found.
     */
    @NonNull
    private static List<Element> getTags(@NonNull Element element, @NonNull String tagName) {
        List<Element> tagList = Lists.newArrayList();
        if (element.getTagName().equalsIgnoreCase(tagName)) {
            tagList.add(element);
        } else {
            NodeList children = element.getChildNodes();
            for (int i = 0; i < children.getLength(); i++) {
                Node child = children.item(i);
                if (child instanceof Element) {
                    tagList.addAll(getTags((Element) child, tagName));
                }
            }
        }
        return tagList;
    }

    /**
     * Checks if auto verification is needed. i.e. any intent tag element's autoVerify attribute is
     * set to true.
     *
     * @param intents The intent tag elements.
     * @return true if auto verification is needed.
     */
    private static boolean needAutoVerification(@NonNull List<Element> intents) {
        for (Element intent : intents) {
            if (intent.getAttributeNS(ANDROID_URI, ATTRIBUTE_AUTO_VERIFY).equals(SdkConstants.VALUE_TRUE)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Checks if the element has a sub tag with specific name and specific name attribute.
     *
     * @param element       The tag element.
     * @param tagName       The name of the sub tag.
     * @param nameAttrValue The value of the name attribute.
     * @return If the element has such a sub tag.
     */
    private static boolean hasNamedSubTag(@NonNull Element element, @NonNull String tagName,
            @NonNull String nameAttrValue) {
        NodeList children = element.getElementsByTagName(tagName);
        for (int i = 0; i < children.getLength(); i++) {
            Element e = (Element) children.item(i);
            if (e.getAttributeNS(ANDROID_URI, ATTRIBUTE_NAME).equals(nameAttrValue)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Gets the urls of all the host from which Digital Asset Links JSON files will be fetched.
     *
     * @param intent The intent tag element.
     * @return List of JSON file urls.
     */
    @NonNull
    private static Map<String, Attr> getJsonUrl(@NonNull XmlContext context,
            @NonNull Element intent) {
        List<String> schemes = Lists.newArrayList();
        List<Attr> hosts = Lists.newArrayList();
        NodeList dataTags = intent.getElementsByTagName(NODE_DATA);
        for (int k = 0; k < dataTags.getLength(); k++) {
            Element dataTag = (Element) dataTags.item(k);
            String scheme = dataTag.getAttributeNS(ANDROID_URI, ATTR_SCHEME);
            if (scheme.equals("http") || scheme.equals("https")) {
                schemes.add(scheme);
            }
            if (dataTag.hasAttributeNS(ANDROID_URI, ATTR_HOST)) {
                Attr host = dataTag.getAttributeNodeNS(ANDROID_URI, ATTR_HOST);
                hosts.add(host);
            }
        }
        Map<String, Attr> urls = Maps.newHashMap();
        for (String scheme : schemes) {
            for (Attr host : hosts) {
                String hostname = host.getValue();
                if (hostname.startsWith(SdkConstants.MANIFEST_PLACEHOLDER_PREFIX)) {
                    hostname = resolvePlaceHolder(context, hostname);
                    if (hostname == null) {
                        continue;
                    }
                }
                urls.put(scheme + "://" + hostname, host);
            }
        }
        return urls;
    }

    @Nullable
    private static String resolvePlaceHolder(@NonNull XmlContext context,
            @NonNull String hostname) {
        assert hostname.startsWith(SdkConstants.MANIFEST_PLACEHOLDER_PREFIX);
        Variant variant = context.getProject().getCurrentVariant();
        if (variant != null) {
            Map<String, Object> placeHolders = variant.getMergedFlavor().getManifestPlaceholders();
            String name = hostname.substring(MANIFEST_PLACEHOLDER_PREFIX.length(),
                    hostname.length() - MANIFEST_PLACEHOLDER_SUFFIX.length());
            Object value = placeHolders.get(name);
            if (value instanceof String) {
                return value.toString();
            }
        }
        return null;
    }

    /* Normally null. Used for testing. */
    @Nullable
    @VisibleForTesting
    static Map<String, HttpResult> sMockData;

    /**
     * Gets all the Digital Asset Links JSON file asynchronously.
     *
     * @return The map between the host url and the HTTP result.
     */
    private Map<String, HttpResult> getJsonFileAsync() {
        if (sMockData != null) {
            return sMockData;
        }

        ExecutorService executorService = Executors.newCachedThreadPool();
        for (final Map.Entry<String, Attr> url : mJsonHost.entrySet()) {
            Future<HttpResult> future = executorService.submit(
                    () -> getJson(url.getKey() + JSON_RELATIVE_PATH));
            mFutures.put(url.getKey(), future);
        }
        executorService.shutdown();

        Map<String, HttpResult> jsons = Maps.newHashMap();
        for (Map.Entry<String, Future<HttpResult>> future : mFutures.entrySet()) {
            try {
                jsons.put(future.getKey(), future.getValue().get());
            } catch (Exception e) {
                jsons.put(future.getKey(), null);
            }
        }
        return jsons;
    }

    /**
     * Gets the Digital Asset Links JSON file on the website host.
     *
     * @param url The URL of the host on which JSON file will be fetched.
     */
    @NonNull
    private static HttpResult getJson(@NonNull String url) {
        try {
            URL urlObj = new URL(url);
            HttpURLConnection connection = (HttpURLConnection) urlObj.openConnection();
            if (connection == null) {
                return new HttpResult(STATUS_HTTP_CONNECT_FAIL, null);
            }
            try {
                InputStream inputStream = connection.getInputStream();
                if (inputStream == null) {
                    return new HttpResult(connection.getResponseCode(), null);
                }
                try (BufferedReader reader = new BufferedReader(
                        new InputStreamReader(inputStream, UTF_8))) {
                    String line;
                    StringBuilder response = new StringBuilder();
                    while ((line = reader.readLine()) != null) {
                        response.append(line);
                        response.append('\n');
                    }

                    try {
                        JsonElement jsonFile = new JsonParser().parse(response.toString());
                        return new HttpResult(connection.getResponseCode(), jsonFile);
                    } catch (JsonSyntaxException e) {
                        return new HttpResult(STATUS_WRONG_JSON_SYNTAX, null);
                    } catch (RuntimeException e) {
                        return new HttpResult(STATUS_JSON_PARSE_FAIL, null);
                    }
                }
            } finally {
                connection.disconnect();
            }
        } catch (MalformedURLException e) {
            return new HttpResult(STATUS_MALFORMED_URL, null);
        } catch (UnknownHostException e) {
            return new HttpResult(STATUS_UNKNOWN_HOST, null);
        } catch (FileNotFoundException e) {
            return new HttpResult(STATUS_NOT_FOUND, null);
        } catch (IOException e) {
            return new HttpResult(STATUS_HTTP_CONNECT_FAIL, null);
        }
    }

    /**
     * Gets the package names of all the apps from the Digital Asset Links JSON file.
     *
     * @param element The JsonElement of the json file.
     * @return All the package names.
     */
    private static List<String> getPackageNameFromJson(JsonElement element) {
        List<String> packageNames = Lists.newArrayList();
        if (element instanceof JsonArray) {
            JsonArray jsonArray = (JsonArray) element;
            for (int i = 0; i < jsonArray.size(); i++) {
                JsonElement app = jsonArray.get(i);
                if (app instanceof JsonObject) {
                    JsonObject target = ((JsonObject) app).getAsJsonObject("target");
                    if (target != null) {
                        // Checks namespace to ensure it is an app statement.
                        JsonElement namespace = target.get("namespace");
                        JsonElement packageName = target.get("package_name");
                        if (namespace != null && namespace.getAsString().equals("android_app")
                                && packageName != null) {
                            packageNames.add(packageName.getAsString());
                        }
                    }
                }
            }
        }
        return packageNames;
    }

    /* For storing the result of getting Digital Asset Links Json File */
    @VisibleForTesting
    static final class HttpResult {

        /* HTTP response code or others errors related to HTTP connection, JSON file parsing. */
        private final int mStatus;
        private final JsonElement mJsonFile;

        @VisibleForTesting
        HttpResult(int status, JsonElement jsonFile) {
            mStatus = status;
            mJsonFile = jsonFile;
        }
    }
}
