/*
 * (C) Copyright 2015 Boni Garcia (http://bonigarcia.github.io/)
 *
 * 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 io.github.bonigarcia.wdm;

import static io.github.bonigarcia.wdm.Architecture.X32;
import static io.github.bonigarcia.wdm.Architecture.X64;
import static io.github.bonigarcia.wdm.Config.isNullOrEmpty;
import static io.github.bonigarcia.wdm.DriverManagerType.CHROME;
import static io.github.bonigarcia.wdm.DriverManagerType.EDGE;
import static io.github.bonigarcia.wdm.DriverManagerType.FIREFOX;
import static io.github.bonigarcia.wdm.DriverManagerType.IEXPLORER;
import static io.github.bonigarcia.wdm.DriverManagerType.OPERA;
import static io.github.bonigarcia.wdm.DriverManagerType.PHANTOMJS;
import static io.github.bonigarcia.wdm.DriverManagerType.SELENIUM_SERVER_STANDALONE;
import static io.github.bonigarcia.wdm.OperatingSystem.WIN;
import static io.github.bonigarcia.wdm.Shell.getVersionFromPosixOutput;
import static io.github.bonigarcia.wdm.Shell.getVersionFromWmicOutput;
import static io.github.bonigarcia.wdm.Shell.runAndWait;
import static java.lang.Integer.parseInt;
import static java.lang.Integer.signum;
import static java.lang.Integer.valueOf;
import static java.lang.invoke.MethodHandles.lookup;
import static java.util.Collections.sort;
import static java.util.Optional.empty;
import static javax.xml.xpath.XPathConstants.NODESET;
import static javax.xml.xpath.XPathFactory.newInstance;
import static org.apache.commons.io.FileUtils.deleteDirectory;
import static org.apache.commons.io.FileUtils.listFiles;
import static org.apache.commons.io.FilenameUtils.removeExtension;
import static org.apache.commons.lang3.StringUtils.isNumeric;
import static org.apache.commons.lang3.SystemUtils.IS_OS_LINUX;
import static org.apache.commons.lang3.SystemUtils.IS_OS_MAC;
import static org.apache.commons.lang3.SystemUtils.IS_OS_WINDOWS;
import static org.slf4j.LoggerFactory.getLogger;

import java.io.BufferedReader;
import java.io.File;
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.URL;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.apache.commons.codec.binary.Base64;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.jsoup.Jsoup;
import org.slf4j.Logger;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.internal.LinkedTreeMap;

/**
 * Parent driver manager.
 *
 * @author Boni Garcia (boni.gg@gmail.com)
 * @since 2.1.0
 */
public abstract class WebDriverManager {

    static final Logger log = getLogger(lookup().lookupClass());

    protected static final String SLASH = "/";
    protected static final String INSIDERS = "insiders";
    protected static final String BETA = "beta";
    protected static final String ONLINE = "online";
    protected static final String LOCAL = "local";

    protected abstract List<URL> getDrivers() throws IOException;

    protected abstract Optional<String> getBrowserVersion();

    protected abstract DriverManagerType getDriverManagerType();

    protected abstract String getDriverName();

    protected abstract void setDriverVersion(String version);

    protected abstract String getDriverVersion();

    protected abstract void setDriverUrl(URL url);

    protected abstract URL getDriverUrl();

    protected abstract Optional<URL> getMirrorUrl();

    protected abstract Optional<String> getExportParameter();

    protected static Map<DriverManagerType, WebDriverManager> instanceMap = new EnumMap<>(
            DriverManagerType.class);

    protected HttpClient httpClient;
    protected Downloader downloader;
    protected UrlFilter urlFilter;
    protected String versionToDownload;
    protected String downloadedVersion;
    protected String latestVersion;
    protected String binaryPath;
    protected boolean mirrorLog;
    protected List<String> listVersions;
    protected boolean forcedArch;
    protected boolean forcedOs;
    protected boolean isLatest;
    protected boolean retry = true;
    protected Config config = new Config();
    protected Preferences preferences = new Preferences(config);
    protected String preferenceKey;
    protected Properties versionsProperties;

    public static Config globalConfig() {
        Config global = new Config();
        global.setAvoidAutoReset(true);
        for (DriverManagerType type : DriverManagerType.values()) {
            WebDriverManager.getInstance(type).setConfig(global);
        }
        return global;
    }

    public Config config() {
        return config;
    }

    public static synchronized WebDriverManager chromedriver() {
        if (!instanceMap.containsKey(CHROME)) {
            instanceMap.put(CHROME, new ChromeDriverManager());
        }
        return instanceMap.get(CHROME);
    }

    public static synchronized WebDriverManager firefoxdriver() {
        if (!instanceMap.containsKey(FIREFOX)) {
            instanceMap.put(FIREFOX, new FirefoxDriverManager());
        }
        return instanceMap.get(FIREFOX);
    }

    public static synchronized WebDriverManager operadriver() {
        if (!instanceMap.containsKey(OPERA)) {
            instanceMap.put(OPERA, new OperaDriverManager());
        }
        return instanceMap.get(OPERA);
    }

    public static synchronized WebDriverManager edgedriver() {
        if (!instanceMap.containsKey(EDGE)) {
            instanceMap.put(EDGE, new EdgeDriverManager());
        }
        return instanceMap.get(EDGE);
    }

    public static synchronized WebDriverManager iedriver() {
        if (!instanceMap.containsKey(IEXPLORER)) {
            instanceMap.put(IEXPLORER, new InternetExplorerDriverManager());
        }
        return instanceMap.get(IEXPLORER);
    }

    public static synchronized WebDriverManager phantomjs() {
        if (!instanceMap.containsKey(PHANTOMJS)) {
            instanceMap.put(PHANTOMJS, new PhantomJsDriverManager());
        }
        return instanceMap.get(PHANTOMJS);
    }

    public static synchronized WebDriverManager seleniumServerStandalone() {
        if (!instanceMap.containsKey(SELENIUM_SERVER_STANDALONE)) {
            instanceMap.put(SELENIUM_SERVER_STANDALONE,
                    new SeleniumServerStandaloneManager());
        }
        return instanceMap.get(SELENIUM_SERVER_STANDALONE);
    }

    protected static synchronized WebDriverManager voiddriver() {
        return new VoidDriverManager();
    }

    public static synchronized WebDriverManager getInstance(
            DriverManagerType driverManagerType) {
        if (driverManagerType == null) {
            return voiddriver();
        }
        switch (driverManagerType) {
        case CHROME:
            return chromedriver();
        case FIREFOX:
            return firefoxdriver();
        case OPERA:
            return operadriver();
        case IEXPLORER:
            return iedriver();
        case EDGE:
            return edgedriver();
        case PHANTOMJS:
            return phantomjs();
        case SELENIUM_SERVER_STANDALONE:
            return seleniumServerStandalone();
        default:
            return voiddriver();
        }
    }

    public static synchronized WebDriverManager getInstance(
            Class<?> webDriverClass) {
        switch (webDriverClass.getName()) {
        case "org.openqa.selenium.chrome.ChromeDriver":
            return chromedriver();
        case "org.openqa.selenium.firefox.FirefoxDriver":
            return firefoxdriver();
        case "org.openqa.selenium.opera.OperaDriver":
            return operadriver();
        case "org.openqa.selenium.ie.InternetExplorerDriver":
            return iedriver();
        case "org.openqa.selenium.edge.EdgeDriver":
            return edgedriver();
        case "org.openqa.selenium.phantomjs.PhantomJSDriver":
            return phantomjs();
        default:
            return voiddriver();
        }
    }

    public synchronized void setup() {
        if (getDriverManagerType() != null) {
            try {
                Architecture architecture = config().getArchitecture();
                String driverVersion = getDriverVersion();
                isLatest = isVersionLatest(driverVersion);
                manage(architecture, driverVersion);
            } finally {
                if (!config().isAvoidAutoReset()) {
                    reset();
                }
            }
        }
    }

    public WebDriverManager version(String version) {
        setDriverVersion(version);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager architecture(Architecture architecture) {
        config().setArchitecture(architecture);
        forcedArch = true;
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager arch32() {
        architecture(X32);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager arch64() {
        architecture(X64);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager operatingSystem(OperatingSystem os) {
        config().setOs(os.name());
        forcedOs = true;
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager forceCache() {
        config().setForceCache(true);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager forceDownload() {
        config().setOverride(true);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager driverRepositoryUrl(URL url) {
        setDriverUrl(url);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager useMirror() {
        Optional<URL> mirrorUrl = getMirrorUrl();
        if (!mirrorUrl.isPresent()) {
            throw new WebDriverManagerException("Mirror URL not available");
        }
        config().setUseMirror(true);
        setDriverUrl(mirrorUrl.get());
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager proxy(String proxy) {
        config().setProxy(proxy);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager proxyUser(String proxyUser) {
        config().setProxyUser(proxyUser);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager proxyPass(String proxyPass) {
        config().setProxyPass(proxyPass);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager useBetaVersions() {
        config().setUseBetaVersions(true);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager ignoreVersions(String... versions) {
        config().setIgnoreVersions(versions);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager gitHubTokenName(String gitHubTokenName) {
        config().setGitHubTokenName(gitHubTokenName);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager gitHubTokenSecret(String gitHubTokenSecret) {
        config().setGitHubTokenSecret(gitHubTokenSecret);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager timeout(int timeout) {
        config().setTimeout(timeout);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager properties(String properties) {
        config().setProperties(properties);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager targetPath(String targetPath) {
        config().setTargetPath(targetPath);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager avoidExport() {
        config().setAvoidExport(true);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager avoidOutputTree() {
        config().setAvoidOutputTree(true);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager avoidAutoVersion() {
        config().setAvoidAutoVersion(true);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager avoidPreferences() {
        config().setAvoidPreferences(true);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager ttl(int seconds) {
        config().setTtl(seconds);
        return instanceMap.get(getDriverManagerType());
    }

    public WebDriverManager browserPath(String browserPath) {
        config().setBinaryPath(browserPath);
        return instanceMap.get(getDriverManagerType());
    }

    // ------------

    public String getBinaryPath() {
        return instanceMap.get(getDriverManagerType()).binaryPath;
    }

    public String getDownloadedVersion() {
        return instanceMap.get(getDriverManagerType()).downloadedVersion;
    }

    public List<String> getVersions() {
        httpClient = new HttpClient(config());
        try {
            List<URL> drivers = getDrivers();
            List<String> versions = new ArrayList<>();
            for (URL url : drivers) {
                String version = getCurrentVersion(url, getDriverName());
                if (version.isEmpty() || version.equalsIgnoreCase("icons")) {
                    continue;
                }
                if (version.startsWith(".")) {
                    version = version.substring(1);
                }
                if (!versions.contains(version)) {
                    versions.add(version);
                }
            }
            log.trace("Version list before sorting {}", versions);
            sort(versions, new VersionComparator());
            return versions;
        } catch (IOException e) {
            throw new WebDriverManagerException(e);
        }
    }

    public void clearPreferences() {
        instanceMap.get(getDriverManagerType()).preferences.clear();
    }

    public void clearCache() {
        String targetPath = config().getTargetPath();
        try {
            log.debug("Clearing cache at {}", targetPath);
            deleteDirectory(new File(targetPath));
        } catch (Exception e) {
            log.warn("Exception deleting cache at {}", targetPath, e);
        }
    }

    // ------------

    protected String preDownload(String target, String version) {
        log.trace("Pre-download. target={}, version={}", target, version);
        return target;
    }

    protected File postDownload(File archive) {
        File parentFolder = archive.getParentFile();
        File[] ls = parentFolder.listFiles();
        for (File f : ls) {
            if (getDriverName().contains(removeExtension(f.getName()))) {
                log.trace("Found binary in post-download: {}", f);
                return f;
            }
        }
        throw new WebDriverManagerException("Driver " + getDriverName()
                + " not found (using temporal folder " + parentFolder + ")");
    }

    protected String getCurrentVersion(URL url, String driverName) {
        String currentVersion = "";
        try {
            currentVersion = url.getFile().substring(
                    url.getFile().indexOf(SLASH) + 1,
                    url.getFile().lastIndexOf(SLASH));
        } catch (StringIndexOutOfBoundsException e) {
            log.trace("Exception getting version of URL {} ({})", url,
                    e.getMessage());
        }

        return currentVersion;
    }

    protected void manage(Architecture arch, String version) {
        httpClient = new HttpClient(config());
        try (HttpClient wdmHttpClient = httpClient) {
            downloader = new Downloader(getDriverManagerType());
            urlFilter = new UrlFilter();

            boolean getLatest = isVersionLatest(version);
            boolean cache = config().isForceCache();

            if (getLatest) {
                version = detectDriverVersionFromBrowser();
            }
            getLatest = isNullOrEmpty(version);

            // For Edge
            if (checkInsiderVersion(version)) {
                return;
            }

            String os = config().getOs();
            log.trace("Managing {} arch={} version={} getLatest={} cache={}",
                    getDriverName(), arch, version, getLatest, cache);

            if (getLatest && latestVersion != null) {
                log.debug("Latest version of {} is {} (recently resolved)",
                        getDriverName(), latestVersion);
                version = latestVersion;
                cache = true;
            }

            Optional<String> driverInCache = handleCache(arch, version, os,
                    getLatest, cache);

            String versionStr = getLatest ? "(latest version)" : version;
            if (driverInCache.isPresent() && !config().isOverride()) {
                storeVersionToDownload(version);
                downloadedVersion = version;
                log.debug("Driver {} {} found in cache", getDriverName(),
                        versionStr);
                exportDriver(driverInCache.get());
            } else {
                List<URL> candidateUrls = filterCandidateUrls(arch, version,
                        getLatest);

                if (candidateUrls.isEmpty()) {
                    String errorMessage = getDriverName() + " " + versionStr
                            + " for " + os + arch.toString() + " not found in "
                            + getDriverUrl();
                    log.error(errorMessage);
                    throw new WebDriverManagerException(errorMessage);
                }

                downloadCandidateUrls(candidateUrls);
            }

        } catch (Exception e) {
            handleException(e, arch, version);
        }
    }

    private String detectDriverVersionFromBrowser() {
        String version = "";
        if (config().isAvoidAutoVersion()) {
            return version;
        }

        Optional<String> optionalBrowserVersion = getBrowserVersion();
        if (optionalBrowserVersion.isPresent()) {
            String browserVersion = optionalBrowserVersion.get();
            log.trace("Detected {} version {}", getDriverManagerType(),
                    browserVersion);
            preferenceKey = getDriverManagerType().name().toLowerCase()
                    + browserVersion;

            if (usePreferences()
                    && preferences.checkKeyInPreferences(preferenceKey)) {
                // Get driver version from preferences
                version = preferences.getValueFromPreferences(preferenceKey);
            } else {
                // Get driver version from properties
                version = getVersionForInstalledBrowser(browserVersion);
            }
            if (!isNullOrEmpty(version)) {
                log.info(
                        "Using {} {} (since {} {} is installed in your machine)",
                        getDriverName(), version, getDriverManagerType(),
                        browserVersion);
            }
        } else {
            log.debug(
                    "The proper {} version for your {} is unknown ... trying with the latest",
                    getDriverName(), getDriverManagerType());
        }

        return version;
    }

    private boolean usePreferences() {
        boolean usePrefs = !config().isAvoidPreferences()
                && !config().isOverride() && !forcedArch && !forcedOs;
        log.trace("Using preferences {}", usePrefs);
        return usePrefs;
    }

    private boolean checkInsiderVersion(String version) {
        if (version.equals(INSIDERS)) {
            String systemRoot = System.getenv("SystemRoot");
            File microsoftWebDriverFile = new File(systemRoot,
                    "System32" + File.separator + "MicrosoftWebDriver.exe");
            if (microsoftWebDriverFile.exists()) {
                downloadedVersion = INSIDERS;
                exportDriver(microsoftWebDriverFile.toString());
                return true;
            } else {
                retry = false;
                throw new WebDriverManagerException(
                        "MicrosoftWebDriver.exe should be installed in an elevated command prompt executing: "
                                + "dism /Online /Add-Capability /CapabilityName:Microsoft.WebDriver~~~~0.0.1.0");
            }
        }
        return false;
    }

    private boolean isVersionLatest(String version) {
        return isNullOrEmpty(version) || version.equalsIgnoreCase("latest");
    }

    private String getVersionForInstalledBrowser(String browserVersion) {
        String driverVersion = "";
        DriverManagerType driverManagerType = getDriverManagerType();
        String driverLowerCase = driverManagerType.name().toLowerCase();
        Optional<String> driverVersionForBrowser = getDriverVersionForBrowserFromProperties(
                driverLowerCase + browserVersion, false);
        if (driverVersionForBrowser.isPresent()) {
            driverVersion = driverVersionForBrowser.get();
        } else {
            log.debug(
                    "The driver version for {} {} is unknown ... trying with latest",
                    driverManagerType, browserVersion);
        }
        return driverVersion;
    }

    private Optional<String> getDriverVersionForBrowserFromProperties(
            String key, boolean online) {
        String onlineMessage = online ? ONLINE : LOCAL;
        log.trace("Getting driver version from {} properties for {}",
                onlineMessage, key);
        String value = getVersionFromProperties(online).getProperty(key);
        if (value == null) {
            log.debug("Driver for {} not found in {} properties", key,
                    onlineMessage);
            versionsProperties = null;
            value = getVersionFromProperties(!online).getProperty(key);
        }
        return value == null ? empty() : Optional.of(value);
    }

    private Properties getVersionFromProperties(boolean online) {
        if (versionsProperties != null) {
            log.trace("Already created versions.properties");
            return versionsProperties;
        } else {
            try {
                InputStream inputStream = getVersionsInputStream(online);
                versionsProperties = new Properties();
                versionsProperties.load(inputStream);
                inputStream.close();
            } catch (Exception e) {
                versionsProperties = null;
                throw new IllegalStateException(
                        "Cannot read versions.properties", e);
            }
            return versionsProperties;
        }
    }

    private InputStream getVersionsInputStream(boolean online)
            throws IOException {
        String onlineMessage = online ? ONLINE : LOCAL;
        log.trace("Reading {} version.properties to find out driver version",
                onlineMessage);
        InputStream inputStream;
        try {
            if (online) {
                inputStream = getOnlineVersionsInputStream();
            } else {
                inputStream = getLocalVersionsInputStream();
            }
        } catch (Exception e) {
            String exceptionMessage = online ? LOCAL : ONLINE;
            log.warn("Error reading version.properties, using {} instead",
                    exceptionMessage);
            if (online) {
                inputStream = getLocalVersionsInputStream();
            } else {
                inputStream = getOnlineVersionsInputStream();
            }
        }
        return inputStream;
    }

    private InputStream getLocalVersionsInputStream() {
        InputStream inputStream;
        inputStream = Config.class.getResourceAsStream("/versions.properties");
        return inputStream;
    }

    private InputStream getOnlineVersionsInputStream() throws IOException {
        return httpClient
                .execute(httpClient
                        .createHttpGet(config().getVersionsPropertiesUrl()))
                .getEntity().getContent();
    }

    protected void handleException(Exception e, Architecture arch,
            String version) {
        String versionStr = isNullOrEmpty(version) ? "(latest version)"
                : version;
        String errorMessage = String.format(
                "There was an error managing %s %s (%s)", getDriverName(),
                versionStr, e.getMessage());
        if (!config().isForceCache() && retry) {
            config().setForceCache(true);
            config().setUseMirror(true);
            retry = false;
            log.warn("{} ... trying again using cache and mirror",
                    errorMessage);
            manage(arch, version);
        } else {
            log.error("{}", errorMessage, e);
            throw new WebDriverManagerException(e);
        }
    }

    protected void downloadCandidateUrls(List<URL> candidateUrls)
            throws IOException, InterruptedException {
        URL url = candidateUrls.iterator().next();

        String exportValue = downloader.download(url, versionToDownload,
                getDriverName());
        exportDriver(exportValue);
        downloadedVersion = versionToDownload;
    }

    protected List<URL> filterCandidateUrls(Architecture arch, String version,
            boolean getLatest) throws IOException {
        List<URL> urls = getDrivers();
        List<URL> candidateUrls;
        log.trace("All URLs: {}", urls);

        boolean continueSearchingVersion;
        do {
            // Get the latest or concrete version
            candidateUrls = getLatest ? checkLatest(urls, getDriverName())
                    : getVersion(urls, getDriverName(), version);
            log.trace("Candidate URLs: {}", candidateUrls);
            if (versionToDownload == null
                    || this.getClass().equals(EdgeDriverManager.class)) {
                break;
            }

            // Filter by OS
            if (!getDriverName().equalsIgnoreCase("IEDriverServer")
                    && !getDriverName()
                            .equalsIgnoreCase("selenium-server-standalone")) {
                candidateUrls = urlFilter.filterByOs(candidateUrls,
                        config().getOs());
            }

            // Filter by architecture
            candidateUrls = urlFilter.filterByArch(candidateUrls, arch,
                    forcedArch);

            // Filter by distro
            candidateUrls = filterByDistro(candidateUrls);

            // Filter by ignored versions
            candidateUrls = filterByIgnoredVersions(candidateUrls);

            // Find out if driver version has been found or not
            continueSearchingVersion = candidateUrls.isEmpty() && getLatest;
            if (continueSearchingVersion) {
                log.info(
                        "No binary found for {} {} ... seeking another version",
                        getDriverName(), versionToDownload);
                urls = removeFromList(urls, versionToDownload);
                versionToDownload = null;
            }
        } while (continueSearchingVersion);
        return candidateUrls;
    }

    protected List<URL> filterByIgnoredVersions(List<URL> candidateUrls) {
        if (config().getIgnoreVersions() != null && !candidateUrls.isEmpty()) {
            candidateUrls = urlFilter.filterByIgnoredVersions(candidateUrls,
                    config().getIgnoreVersions());
        }
        return candidateUrls;
    }

    protected List<URL> filterByDistro(List<URL> candidateUrls)
            throws IOException {
        // Filter phantomjs 2.5.0 in Linux
        if (config().getOs().equalsIgnoreCase("linux")
                && getDriverName().contains("phantomjs")) {
            candidateUrls = urlFilter.filterByDistro(candidateUrls, "2.5.0");
        }
        return candidateUrls;
    }

    protected Optional<String> handleCache(Architecture arch, String version,
            String os, boolean getLatest, boolean cache) {
        Optional<String> driverInCache = empty();
        if (cache || !getLatest) {
            driverInCache = getDriverFromCache(version, arch, os);
        }
        storeVersionToDownload(version);
        return driverInCache;
    }

    protected Optional<String> getDriverFromCache(String driverVersion,
            Architecture arch, String os) {
        log.trace("Checking if {} exists in cache", getDriverName());
        List<File> filesInCache = getFilesInCache();
        if (!filesInCache.isEmpty()) {
            // Filter by name
            filesInCache = filterCacheBy(filesInCache, getDriverName());

            // Filter by version
            filesInCache = filterCacheBy(filesInCache, driverVersion);

            // Filter by OS
            if (!getDriverName().equals("MicrosoftWebDriver")) {
                filesInCache = filterCacheBy(filesInCache, os.toLowerCase());
            }

            if (filesInCache.size() == 1) {
                return Optional.of(filesInCache.get(0).toString());
            }

            // Filter by arch
            filesInCache = filterCacheBy(filesInCache, arch.toString());

            if (!filesInCache.isEmpty()) {
                return Optional.of(
                        filesInCache.get(filesInCache.size() - 1).toString());
            }
        }

        log.trace("{} not found in cache", getDriverName());
        return empty();
    }

    protected List<File> filterCacheBy(List<File> input, String key) {
        List<File> output = new ArrayList<>(input);
        if (!key.isEmpty() && !input.isEmpty()) {
            for (File f : input) {
                if (!f.toString().contains(key)) {
                    output.remove(f);
                }
            }
        }
        log.trace("Filter cache by {} -- input list {} -- output list {} ", key,
                input, output);
        return output;
    }

    protected List<File> getFilesInCache() {
        return (List<File>) listFiles(new File(downloader.getTargetPath()),
                null, true);
    }

    protected List<URL> removeFromList(List<URL> list, String version) {
        List<URL> out = new ArrayList<>(list);
        for (URL url : list) {
            if (url.getFile().contains(version)) {
                out.remove(url);
            }
        }
        return out;
    }

    protected List<URL> getVersion(List<URL> list, String driver,
            String version) {
        List<URL> out = new ArrayList<>();
        if (getDriverName().contains("MicrosoftWebDriver")) {
            int i = listVersions.indexOf(version);
            if (i != -1) {
                out.add(list.get(i));
            }
        }

        for (URL url : list) {
            if (url.getFile().contains(driver)
                    && url.getFile().contains(version)
                    && !url.getFile().contains("-symbols")) {
                out.add(url);
            }
        }

        if (versionToDownload != null && !versionToDownload.equals(version)) {
            versionToDownload = version;
            log.info("Using {} {}", driver, version);
        }
        return out;
    }

    protected List<URL> checkLatest(List<URL> list, String driver) {
        log.trace("Checking the lastest version of {} with URL list {}", driver,
                list);
        List<URL> out = new ArrayList<>();
        List<URL> copyOfList = new ArrayList<>(list);

        for (URL url : copyOfList) {
            try {
                handleDriver(url, driver, out);
            } catch (Exception e) {
                log.trace("There was a problem with URL {} : {}", url,
                        e.getMessage());
                list.remove(url);
            }
        }
        storeVersionToDownload(versionToDownload);
        latestVersion = versionToDownload;
        log.info("Latest version of {} is {}", driver, versionToDownload);
        return out;
    }

    protected void handleDriver(URL url, String driver, List<URL> out) {
        if (!config().isUseBetaVersions()
                && (url.getFile().toLowerCase().contains("beta"))) {
            return;
        }

        if (url.getFile().contains(driver)) {
            String currentVersion = getCurrentVersion(url, driver);

            if (currentVersion.equalsIgnoreCase(driver)) {
                return;
            }
            if (versionToDownload == null) {
                versionToDownload = currentVersion;
            }
            if (versionCompare(currentVersion, versionToDownload) > 0) {
                versionToDownload = currentVersion;
                out.clear();
            }
            if (url.getFile().contains(versionToDownload)) {
                out.add(url);
            }
        }
    }

    protected boolean isUsingTaobaoMirror() {
        return getDriverUrl().getHost().equalsIgnoreCase("npm.taobao.org");
    }

    protected Integer versionCompare(String str1, String str2) {
        String[] vals1 = str1.replaceAll("v", "").split("\\.");
        String[] vals2 = str2.replaceAll("v", "").split("\\.");

        if (vals1[0].equals("")) {
            vals1[0] = "0";
        }
        if (vals2[0].equals("")) {
            vals2[0] = "0";
        }

        int i = 0;
        while (i < vals1.length && i < vals2.length
                && vals1[i].equals(vals2[i])) {
            i++;
        }

        if (i < vals1.length && i < vals2.length) {
            return signum(valueOf(vals1[i]).compareTo(valueOf(vals2[i])));
        } else {
            return signum(vals1.length - vals2.length);
        }
    }

    /**
     * This method works also for http://npm.taobao.org/ and
     * https://bitbucket.org/ mirrors.
     */
    protected List<URL> getDriversFromMirror(URL driverUrl) throws IOException {
        if (mirrorLog) {
            log.info("Crawling driver list from mirror {}", driverUrl);
            mirrorLog = true;
        } else {
            log.trace("[Recursive call] Crawling driver list from mirror {}",
                    driverUrl);
        }

        String driverStr = driverUrl.toString();
        String driverUrlContent = driverUrl.getPath();

        HttpResponse response = httpClient
                .execute(httpClient.createHttpGet(driverUrl));
        try (InputStream in = response.getEntity().getContent()) {
            org.jsoup.nodes.Document doc = Jsoup.parse(in, null, "");
            Iterator<org.jsoup.nodes.Element> iterator = doc.select("a")
                    .iterator();
            List<URL> urlList = new ArrayList<>();

            while (iterator.hasNext()) {
                String link = iterator.next().attr("href");
                if (link.contains("mirror") && link.endsWith(SLASH)) {
                    urlList.addAll(getDriversFromMirror(new URL(
                            driverStr + link.replace(driverUrlContent, ""))));
                } else if (link.startsWith(driverUrlContent)
                        && !link.contains("icons")) {
                    urlList.add(new URL(
                            driverStr + link.replace(driverUrlContent, "")));
                }
            }
            return urlList;
        }
    }

    protected List<URL> getDriversFromXml(URL driverUrl) throws IOException {
        log.info("Reading {} to seek {}", driverUrl, getDriverName());
        List<URL> urls = new ArrayList<>();
        HttpResponse response = httpClient
                .execute(httpClient.createHttpGet(driverUrl));
        try {
            try (BufferedReader reader = new BufferedReader(
                    new InputStreamReader(response.getEntity().getContent()))) {
                Document xml = loadXML(reader);
                NodeList nodes = (NodeList) newInstance().newXPath().evaluate(
                        "//Contents/Key", xml.getDocumentElement(), NODESET);

                for (int i = 0; i < nodes.getLength(); ++i) {
                    Element e = (Element) nodes.item(i);
                    urls.add(new URL(driverUrl.toURI().resolve(".")
                            + e.getChildNodes().item(0).getNodeValue()));
                }
            }
        } catch (Exception e) {
            throw new WebDriverManagerException(e);
        }
        return urls;
    }

    protected Document loadXML(Reader reader)
            throws SAXException, IOException, ParserConfigurationException {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        DocumentBuilder builder = factory.newDocumentBuilder();
        InputSource is = new InputSource(reader);
        return builder.parse(is);
    }

    protected void exportDriver(String variableValue) {
        binaryPath = variableValue;
        Optional<String> exportParameter = getExportParameter();
        if (!config.isAvoidExport() && exportParameter.isPresent()) {
            String variableName = exportParameter.get();
            log.info("Exporting {} as {}", variableName, variableValue);
            System.setProperty(variableName, variableValue);
        } else {
            log.info("Resulting binary {}", variableValue);
        }
    }

    protected InputStream openGitHubConnection(URL driverUrl)
            throws IOException {
        HttpGet get = httpClient.createHttpGet(driverUrl);

        String gitHubTokenName = config().getGitHubTokenName();
        String gitHubTokenSecret = config().getGitHubTokenSecret();
        if (!isNullOrEmpty(gitHubTokenName)
                && !isNullOrEmpty(gitHubTokenSecret)) {
            String userpass = gitHubTokenName + ":" + gitHubTokenSecret;
            String basicAuth = "Basic "
                    + new String(new Base64().encode(userpass.getBytes()));
            get.addHeader("Authorization", basicAuth);
        }

        return httpClient.execute(get).getEntity().getContent();
    }

    protected List<URL> getDriversFromGitHub() throws IOException {
        List<URL> urls;
        URL driverUrl = getDriverUrl();
        log.info("Reading {} to seek {}", driverUrl, getDriverName());

        if (isUsingTaobaoMirror()) {
            urls = getDriversFromMirror(driverUrl);

        } else {
            String driverVersion = versionToDownload;

            try (BufferedReader reader = new BufferedReader(
                    new InputStreamReader(openGitHubConnection(driverUrl)))) {

                GsonBuilder gsonBuilder = new GsonBuilder();
                Gson gson = gsonBuilder.create();
                GitHubApi[] releaseArray = gson.fromJson(reader,
                        GitHubApi[].class);

                if (driverVersion != null) {
                    releaseArray = new GitHubApi[] {
                            getVersion(releaseArray, driverVersion) };
                }

                urls = new ArrayList<>();
                for (GitHubApi release : releaseArray) {
                    if (release != null) {
                        List<LinkedTreeMap<String, Object>> assets = release
                                .getAssets();
                        for (LinkedTreeMap<String, Object> asset : assets) {
                            urls.add(new URL(asset.get("browser_download_url")
                                    .toString()));
                        }
                    }
                }
            }
        }
        return urls;
    }

    protected GitHubApi getVersion(GitHubApi[] releaseArray, String version) {
        GitHubApi out = null;
        for (GitHubApi release : releaseArray) {
            log.trace("Get version {} of {}", version, release);
            if ((release.getName() != null
                    && release.getName().contains(version))
                    || (release.getTagName() != null
                            && release.getTagName().contains(version))) {
                out = release;
                break;
            }
        }
        return out;
    }

    protected HttpClient getHttpClient() {
        return httpClient;
    }

    protected FilenameFilter getFolderFilter() {
        return (dir, name) -> dir.isDirectory()
                && name.toLowerCase().contains(getDriverName());
    }

    protected Optional<String> getDefaultBrowserVersion(String programFilesEnv,
            String winBrowserName, String linuxBrowserName,
            String macBrowserName, String versionFlag,
            String browserNameInOutput) {
        String browserBinaryPath = config().getBinaryPath();
        if (IS_OS_WINDOWS) {
            String programFiles = System.getenv(programFilesEnv)
                    .replaceAll("\\\\", "\\\\\\\\");

            String browserPath = isNullOrEmpty(browserBinaryPath)
                    ? programFiles + winBrowserName
                    : browserBinaryPath;
            String browserVersionOutput = runAndWait(getExecFile(), "wmic",
                    "datafile", "where", "name='" + browserPath + "'", "get",
                    "Version", "/value");
            if (!isNullOrEmpty(browserVersionOutput)) {
                return Optional
                        .of(getVersionFromWmicOutput(browserVersionOutput));
            }
        } else if (IS_OS_LINUX || IS_OS_MAC) {
            String browserPath;
            if (!isNullOrEmpty(browserBinaryPath)) {
                browserPath = browserBinaryPath;
            } else {
                browserPath = IS_OS_LINUX ? linuxBrowserName : macBrowserName;
            }
            String browserVersionOutput = runAndWait(browserPath, versionFlag);
            if (!isNullOrEmpty(browserVersionOutput)) {
                return Optional.of(getVersionFromPosixOutput(
                        browserVersionOutput, browserNameInOutput));
            }
        }
        return empty();
    }

    protected File getExecFile() {
        String systemRoot = System.getenv("SystemRoot");
        File system32 = new File(systemRoot, "System32");
        if (IS_OS_WINDOWS && system32.exists() && system32.isDirectory()) {
            return system32;
        }
        return new File(".");
    }

    protected void reset() {
        config().reset();
        mirrorLog = false;
        listVersions = null;
        versionToDownload = null;
        forcedArch = false;
        forcedOs = false;
        retry = true;
        isLatest = true;
    }

    protected String getProgramFilesEnv() {
        return System.getProperty("os.arch").contains("64")
                ? "PROGRAMFILES(X86)"
                : "PROGRAMFILES";
    }

    protected URL getDriverUrlCkeckingMirror(URL url) {
        if (config().isUseMirror()) {
            Optional<URL> mirrorUrl = getMirrorUrl();
            if (mirrorUrl.isPresent()) {
                return mirrorUrl.get();
            }
        }
        return url;
    }

    public static void main(String[] args) {
        String validBrowsers = "chrome|firefox|opera|edge|phantomjs|iexplorer|selenium_server_standalone";
        if (args.length <= 0) {
            logCliError(validBrowsers);
        } else {
            String arg = args[0];
            if (arg.equalsIgnoreCase("server")) {
                startServer(args);
            } else if (arg.equalsIgnoreCase("clear-preferences")) {
                new Preferences(new Config()).clear();
            } else {
                resolveLocal(validBrowsers, arg);
            }
        }
    }

    private static void resolveLocal(String validBrowsers, String arg) {
        log.info("Using WebDriverManager to resolve {}", arg);
        try {
            DriverManagerType driverManagerType = DriverManagerType
                    .valueOf(arg.toUpperCase());
            WebDriverManager wdm = WebDriverManager
                    .getInstance(driverManagerType).avoidExport()
                    .targetPath(".").forceDownload();
            if (arg.equalsIgnoreCase("edge")
                    || arg.equalsIgnoreCase("iexplorer")) {
                wdm.operatingSystem(WIN);
            }
            wdm.avoidOutputTree().setup();
        } catch (Exception e) {
            log.error("Driver for {} not found (valid browsers {})", arg,
                    validBrowsers);
        }
    }

    private static void startServer(String[] args) {
        int port = new Config().getServerPort();
        if (args.length > 1 && isNumeric(args[1])) {
            port = parseInt(args[1]);
        }
        new Server(port);
    }

    private static void logCliError(String validBrowsers) {
        log.error("There are 3 options to run WebDriverManager CLI");
        log.error(
                "1. WebDriverManager used to resolve binary drivers locally:");
        log.error("\tWebDriverManager browserName");
        log.error("\t(where browserName={})", validBrowsers);

        log.error("2. WebDriverManager as a server:");
        log.error("\tWebDriverManager server <port>");
        log.error("\t(where default port is 4041)");

        log.error(
                "3. To clear previously resolved driver versions (as Java preferences):");
        log.error("\tWebDriverManager clear-preferences");
    }

    private void storeVersionToDownload(String version) {
        if (!isNullOrEmpty(version)) {
            if (version.startsWith(".")) {
                version = version.substring(1);
            }
            versionToDownload = version;
            if (isLatest && usePreferences() && !isNullOrEmpty(preferenceKey)) {
                preferences.putValueInPreferencesIfEmpty(preferenceKey,
                        version);
            }
        }
    }

    private void setConfig(Config config) {
        this.config = config;
    }

}


/*
 * Copyright (c) 2015 LingoChamp Inc.
 *
 * 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.liulishuo.filedownloader.notification;

import com.liulishuo.filedownloader.BaseDownloadTask;
import com.liulishuo.filedownloader.FileDownloadList;
import com.liulishuo.filedownloader.FileDownloadListener;

/**
 * The listener of the notification with the task.
 *
 * @see FileDownloadNotificationHelper
 * @see BaseNotificationItem
 */
@SuppressWarnings({"WeakerAccess", "UnusedParameters"})
public abstract class FileDownloadNotificationListener extends FileDownloadListener {
    private final FileDownloadNotificationHelper helper;

    public FileDownloadNotificationListener(FileDownloadNotificationHelper helper) {
        if (helper == null) throw new IllegalArgumentException("helper must not be null!");
        this.helper = helper;
    }

    public FileDownloadNotificationHelper getHelper() {
        return helper;
    }

    public void addNotificationItem(int downloadId) {
        if (downloadId == 0) {
            return;
        }

        BaseDownloadTask.IRunningTask task = FileDownloadList.getImpl().get(downloadId);
        if (task != null) {
            addNotificationItem(task.getOrigin());
        }
    }

    public void addNotificationItem(BaseDownloadTask task) {
        if (disableNotification(task)) {
            return;
        }

        final BaseNotificationItem n = create(task);
        if (n != null) {
            //noinspection unchecked
            this.helper.add(n);
        }
    }

    /**
     * The notification item with the {@code task} is told to destroy.
     *
     * @param task The task used to identify the will be destroyed notification item.
     */
    public void destroyNotification(BaseDownloadTask task) {
        if (disableNotification(task)) {
            return;
        }

        this.helper.showIndeterminate(task.getId(), task.getStatus());

        final BaseNotificationItem n = this.helper.
                remove(task.getId());
        if (!interceptCancel(task, n) && n != null) {
            n.cancel();
        }
    }

    public void showIndeterminate(BaseDownloadTask task) {
        if (disableNotification(task)) {
            return;
        }

        this.helper.showIndeterminate(task.getId(), task.getStatus());
    }

    public void showProgress(BaseDownloadTask task, int soFarBytes,
                             int totalBytes) {
        if (disableNotification(task)) {
            return;
        }

        this.helper.showProgress(task.getId(), task.getSmallFileSoFarBytes(),
                task.getSmallFileTotalBytes());
    }

    /**
     * @param task The task used to bind with the will be created notification item.
     * @return The notification item is related with the {@code task}.
     */
    protected abstract BaseNotificationItem create(BaseDownloadTask task);

    /**
     * @param task             The task.
     * @param notificationItem The notification item.
     * @return {@code true} if you want to survive the notification item, and we will don't  cancel
     * the relate notification from the notification panel when the relate task is finished,
     * {@code false} otherwise.
     *

     * Default: {@code false}
     * @see #destroyNotification(BaseDownloadTask)
     */
    protected boolean interceptCancel(BaseDownloadTask task,
                                      BaseNotificationItem notificationItem) {
        return false;
    }

    /**
     * @param task The task.
     * @return {@code true} if you want to disable the internal notification lifecycle, and in this
     * case all method about the notification will be invalid, {@code false} otherwise.
     *

     * Default: {@code false}
     */
    protected boolean disableNotification(final BaseDownloadTask task) {
        return false;
    }

    @Override
    protected void pending(BaseDownloadTask task, int soFarBytes, int totalBytes) {
        addNotificationItem(task);
        showIndeterminate(task);
    }

    @Override
    protected void started(BaseDownloadTask task) {
        super.started(task);
        showIndeterminate(task);
    }

    @Override
    protected void progress(BaseDownloadTask task, int soFarBytes, int totalBytes) {
        showProgress(task, soFarBytes, totalBytes);
    }

    @Override
    protected void retry(BaseDownloadTask task, Throwable ex, int retryingTimes, int soFarBytes) {
        super.retry(task, ex, retryingTimes, soFarBytes);
        showIndeterminate(task);
    }

    @Override
    protected void blockComplete(BaseDownloadTask task) {
    }

    @Override
    protected void completed(BaseDownloadTask task) {
        destroyNotification(task);
    }

    @Override
    protected void paused(BaseDownloadTask task, int soFarBytes, int totalBytes) {
        destroyNotification(task);
    }

    @Override
    protected void error(BaseDownloadTask task, Throwable e) {
        destroyNotification(task);
    }

    @Override
    protected void warn(BaseDownloadTask task) {
        // ignore
        // do not handle the case of the same URL and path task(the same download id), which
        // will share the same notification item
//        if (!disableNotification(task)) {
//            destroyNotification(task);
//        }
    }
}


/*
 * Copyright (c) 2015 LingoChamp Inc.
 *
 * 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.liulishuo.filedownloader;

import android.text.TextUtils;
import android.util.SparseArray;

import com.liulishuo.filedownloader.model.FileDownloadHeader;
import com.liulishuo.filedownloader.model.FileDownloadModel;
import com.liulishuo.filedownloader.model.FileDownloadStatus;
import com.liulishuo.filedownloader.util.FileDownloadLog;
import com.liulishuo.filedownloader.util.FileDownloadUtils;

import java.io.File;
import java.util.ArrayList;

/**
 * The download task.
 */

public class DownloadTask implements BaseDownloadTask, BaseDownloadTask.IRunningTask,
        DownloadTaskHunter.ICaptureTask {

    private final ITaskHunter mHunter;
    private final ITaskHunter.IMessageHandler mMessageHandler;
    private int mId;

    private ArrayList<BaseDownloadTask.FinishListener> mFinishListenerList;

    private final String mUrl;
    private String mPath;
    private String mFilename;
    private boolean mPathAsDirectory;

    private FileDownloadHeader mHeader;

    private FileDownloadListener mListener;

    private SparseArray<Object> mKeyedTags;
    private Object mTag;

    private int mAutoRetryTimes = 0;

    /**
     * If {@code true} will callback directly on the download thread(do not on post the message to
     * the ui thread
     * by {@link android.os.Handler#post(Runnable)}
     */
    private boolean mSyncCallback = false;

    private boolean mIsWifiRequired = false;

    public static final int DEFAULT_CALLBACK_PROGRESS_MIN_INTERVAL_MILLIS = 10;
    private int mCallbackProgressTimes = FileDownloadModel.DEFAULT_CALLBACK_PROGRESS_TIMES;
    private int mCallbackProgressMinIntervalMillis = DEFAULT_CALLBACK_PROGRESS_MIN_INTERVAL_MILLIS;

    private boolean mIsForceReDownload = false;

    volatile int mAttachKey = 0;
    private boolean mIsInQueueTask = false;

    DownloadTask(final String url) {
        this.mUrl = url;
        mPauseLock = new Object();
        final DownloadTaskHunter hunter = new DownloadTaskHunter(this, mPauseLock);

        mHunter = hunter;
        mMessageHandler = hunter;
    }

    @Override
    public BaseDownloadTask setMinIntervalUpdateSpeed(int minIntervalUpdateSpeedMs) {
        mHunter.setMinIntervalUpdateSpeed(minIntervalUpdateSpeedMs);
        return this;
    }

    @Override
    public BaseDownloadTask setPath(final String path) {
        return setPath(path, false);
    }

    @Override
    public BaseDownloadTask setPath(final String path, final boolean pathAsDirectory) {
        this.mPath = path;
        if (FileDownloadLog.NEED_LOG) {
            FileDownloadLog.d(this, "setPath %s", path);
        }

        this.mPathAsDirectory = pathAsDirectory;
        if (pathAsDirectory) {
            /**
             * will be found before the callback of
             * {@link FileDownloadListener#connected(BaseDownloadTask, String, boolean, int, int)}
             */
            this.mFilename = null;
        } else {
            this.mFilename = new File(path).getName();
        }

        return this;
    }

    @Override
    public BaseDownloadTask setListener(final FileDownloadListener listener) {
        this.mListener = listener;

        if (FileDownloadLog.NEED_LOG) {
            FileDownloadLog.d(this, "setListener %s", listener);
        }
        return this;
    }

    @Override
    public BaseDownloadTask setCallbackProgressTimes(int callbackProgressCount) {
        this.mCallbackProgressTimes = callbackProgressCount;
        return this;
    }

    @Override
    public BaseDownloadTask setCallbackProgressMinInterval(int minIntervalMillis) {
        this.mCallbackProgressMinIntervalMillis = minIntervalMillis;
        return this;
    }

    @Override
    public BaseDownloadTask setCallbackProgressIgnored() {
        return setCallbackProgressTimes(-1);
    }

    @Override
    public BaseDownloadTask setTag(final Object tag) {
        this.mTag = tag;
        if (FileDownloadLog.NEED_LOG) {
            FileDownloadLog.d(this, "setTag %s", tag);
        }
        return this;
    }

    @Override
    public BaseDownloadTask setTag(final int key, final Object tag) {
        if (mKeyedTags == null) {
            mKeyedTags = new SparseArray<>(2);
        }
        mKeyedTags.put(key, tag);
        return this;
    }

    @Override
    public BaseDownloadTask setForceReDownload(final boolean isForceReDownload) {
        this.mIsForceReDownload = isForceReDownload;
        return this;
    }

    @Override
    public BaseDownloadTask setFinishListener(
            final BaseDownloadTask.FinishListener finishListener) {
        addFinishListener(finishListener);
        return this;
    }

    @Override
    public BaseDownloadTask addFinishListener(
            final BaseDownloadTask.FinishListener finishListener) {
        if (mFinishListenerList == null) {
            mFinishListenerList = new ArrayList<>();
        }

        if (!mFinishListenerList.contains(finishListener)) {
            mFinishListenerList.add(finishListener);
        }
        return this;
    }

    @Override
    public boolean removeFinishListener(final BaseDownloadTask.FinishListener finishListener) {
        return mFinishListenerList != null && mFinishListenerList.remove(finishListener);
    }

    @Override
    public BaseDownloadTask setAutoRetryTimes(int autoRetryTimes) {
        this.mAutoRetryTimes = autoRetryTimes;
        return this;
    }

    @Override
    public BaseDownloadTask addHeader(final String name, final String value) {
        checkAndCreateHeader();
        mHeader.add(name, value);
        return this;
    }

    @Override
    public BaseDownloadTask addHeader(final String line) {
        checkAndCreateHeader();
        mHeader.add(line);
        return this;
    }

    @Override
    public BaseDownloadTask removeAllHeaders(final String name) {
        if (mHeader == null) {
            synchronized (headerCreateLock) {
                // maybe invoking checkAndCreateHear and will to be available.
                if (mHeader == null) {
                    return this;
                }
            }
        }

        mHeader.removeAll(name);
        return this;
    }

    @Override
    public BaseDownloadTask setSyncCallback(final boolean syncCallback) {
        this.mSyncCallback = syncCallback;
        return this;
    }

    @Override
    public BaseDownloadTask setWifiRequired(boolean isWifiRequired) {
        this.mIsWifiRequired = isWifiRequired;
        return this;
    }

    @Override
    public int ready() {
        return asInQueueTask().enqueue();
    }

    @Override
    public InQueueTask asInQueueTask() {
        return new InQueueTaskImpl(this);
    }

    @Override
    public boolean reuse() {
        if (isRunning()) {
            FileDownloadLog.w(this, "This task[%d] is running, if you want start the same task,"
                    + " please create a new one by FileDownloader#create", getId());
            return false;
        }

        this.mAttachKey = 0;
        mIsInQueueTask = false;
        mIsMarkedAdded2List = false;
        mHunter.reset();

        return true;
    }

    @Override
    public boolean isUsing() {
        return mHunter.getStatus() != FileDownloadStatus.INVALID_STATUS;
    }

    @Override
    public boolean isRunning() {
        //noinspection SimplifiableIfStatement
        if (FileDownloader.getImpl().getLostConnectedHandler().isInWaitingList(this)) {
            return true;
        }

        return FileDownloadStatus.isIng(getStatus());
    }

    @Override
    public boolean isAttached() {
        return mAttachKey != 0;
    }

    @Override
    public int start() {
        if (mIsInQueueTask) {
            throw new IllegalStateException("If you start the task manually, it means this task "
                    + "doesn't belong to a queue, so you must not invoke BaseDownloadTask#ready() "
                    + "or InQueueTask#enqueue() before you start() this method. For detail: If this"
                    + " task doesn't belong to a queue, what is just an isolated task, you just "
                    + "need to invoke BaseDownloadTask#start() to start this task, that's all. In"
                    + " other words, If this task doesn't belong to a queue, you must not invoke"
                    + " BaseDownloadTask#ready() method or InQueueTask#enqueue() method before"
                    + " invoke BaseDownloadTask#start(), If you do that and if there is the same"
                    + " listener object to start a queue in another thread, this task may be "
                    + "assembled by the queue, in that case, when you invoke "
                    + "BaseDownloadTask#start() manually to start this task or this task is started"
                    + " by the queue, there is an exception buried in there, because this task"
                    + " object is started two times without declare BaseDownloadTask#reuse() :"
                    + " 1. you invoke BaseDownloadTask#start() manually; "
                    + " 2. the queue start this task automatically.");
        }

        return startTaskUnchecked();
    }

    private int startTaskUnchecked() {
        if (isUsing()) {
            if (isRunning()) {
                throw new IllegalStateException(
                        FileDownloadUtils.formatString("This task is running %d, if you"
                                + " want to start the same task, please create a new one by"
                                + " FileDownloader.create", getId()));
            } else {
                throw new IllegalStateException("This task is dirty to restart, If you want to "
                        + "reuse this task, please invoke #reuse method manually and retry to "
                        + "restart again." + mHunter.toString());
            }
        }

        if (!isAttached()) {
            setAttachKeyDefault();
        }

        mHunter.intoLaunchPool();

        return getId();
    }

    // -------------- Another Operations ---------------------

    private final Object mPauseLock;

    @Override
    public boolean pause() {
        synchronized (mPauseLock) {
            return mHunter.pause();
        }
    }

    @Override
    public boolean cancel() {
        return pause();
    }

    // ------------------- Get -----------------------

    @Override
    public int getId() {
        if (mId != 0) {
            return mId;
        }

        if (!TextUtils.isEmpty(mPath) && !TextUtils.isEmpty(mUrl)) {
            return mId = FileDownloadUtils.generateId(mUrl, mPath, mPathAsDirectory);
        }

        return 0;
    }

    /**
     * @deprecated Used {@link #getId()} instead.
     */
    @Override
    public int getDownloadId() {
        return getId();
    }

    @Override
    public String getUrl() {
        return mUrl;
    }

    @Override
    public int getCallbackProgressTimes() {
        return mCallbackProgressTimes;
    }

    @Override
    public int getCallbackProgressMinInterval() {
        return mCallbackProgressMinIntervalMillis;
    }

    @Override
    public String getPath() {
        return mPath;
    }

    @Override
    public boolean isPathAsDirectory() {
        return mPathAsDirectory;
    }

    @Override
    public String getFilename() {
        return mFilename;
    }

    @Override
    public String getTargetFilePath() {
        return FileDownloadUtils.getTargetFilePath(getPath(), isPathAsDirectory(), getFilename());
    }

    @Override
    public FileDownloadListener getListener() {
        return mListener;
    }

    @Override
    public int getSoFarBytes() {
        return getSmallFileSoFarBytes();
    }

    @Override
    public int getSmallFileSoFarBytes() {
        if (mHunter.getSofarBytes() > Integer.MAX_VALUE) {
            return Integer.MAX_VALUE;
        }
        return (int) mHunter.getSofarBytes();
    }

    @Override
    public long getLargeFileSoFarBytes() {
        return mHunter.getSofarBytes();
    }

    @Override
    public int getTotalBytes() {
        return getSmallFileTotalBytes();
    }

    @Override
    public int getSmallFileTotalBytes() {
        if (mHunter.getTotalBytes() > Integer.MAX_VALUE) {
            return Integer.MAX_VALUE;
        }

        return (int) mHunter.getTotalBytes();
    }

    @Override
    public long getLargeFileTotalBytes() {
        return mHunter.getTotalBytes();
    }

    @Override
    public int getSpeed() {
        return mHunter.getSpeed();
    }

    @Override
    public byte getStatus() {
        return mHunter.getStatus();
    }

    @Override
    public boolean isForceReDownload() {
        return this.mIsForceReDownload;
    }

    @Override
    public Throwable getEx() {
        return getErrorCause();
    }

    @Override
    public Throwable getErrorCause() {
        return mHunter.getErrorCause();
    }

    @Override
    public boolean isReusedOldFile() {
        return mHunter.isReusedOldFile();
    }

    @Override
    public Object getTag() {
        return this.mTag;
    }

    @Override
    public Object getTag(int key) {
        return mKeyedTags == null ? null : mKeyedTags.get(key);
    }

    /**
     * @deprecated Use {@link #isResuming()} instead.
     */
    @Override
    public boolean isContinue() {
        return isResuming();
    }

    @Override
    public boolean isResuming() {
        return mHunter.isResuming();
    }

    @Override
    public String getEtag() {
        return mHunter.getEtag();
    }

    @Override
    public int getAutoRetryTimes() {
        return this.mAutoRetryTimes;
    }

    @Override
    public int getRetryingTimes() {
        return mHunter.getRetryingTimes();
    }

    @Override
    public boolean isSyncCallback() {
        return mSyncCallback;
    }

    @Override
    public boolean isLargeFile() {
        return mHunter.isLargeFile();
    }

    @Override
    public boolean isWifiRequired() {
        return mIsWifiRequired;
    }

    private final Object headerCreateLock = new Object();

    private void checkAndCreateHeader() {
        if (mHeader == null) {
            synchronized (headerCreateLock) {
                if (mHeader == null) {
                    mHeader = new FileDownloadHeader();
                }
            }
        }
    }

    @Override
    public FileDownloadHeader getHeader() {
        return this.mHeader;
    }

    // why this? thread not safe: update,InQueueTask#enqueue, start, pause, start which influence of
    // this
    // in the queue.
    // whether it has been added, whether or not it is removed.
    private volatile boolean mIsMarkedAdded2List = false;

    @Override
    public void markAdded2List() {
        mIsMarkedAdded2List = true;
    }

    @Override
    public void free() {
        mHunter.free();
        if (FileDownloadList.getImpl().isNotContains(this)) {
            mIsMarkedAdded2List = false;
        }
    }

    @Override
    public void startTaskByQueue() {
        startTaskUnchecked();
    }

    @Override
    public void startTaskByRescue() {
        // In this case, we don't need to check, because, we just to rescue this task, it means this
        // task has already called start, but the filedownloader service didn't connected, and now,
        // the service is connected, so we just rescue this task.
        startTaskUnchecked();
    }

    @Override
    public Object getPauseLock() {
        return mPauseLock;
    }

    @Override
    public boolean isContainFinishListener() {
        return mFinishListenerList != null && mFinishListenerList.size() > 0;
    }

    @Override
    public boolean isMarkedAdded2List() {
        return this.mIsMarkedAdded2List;
    }

    @Override
    public BaseDownloadTask.IRunningTask getRunningTask() {
        return this;
    }

    @Override
    public void setFileName(String fileName) {
        mFilename = fileName;
    }

    @Override
    public ArrayList<FinishListener> getFinishListenerList() {
        return mFinishListenerList;
    }

    @Override
    public BaseDownloadTask getOrigin() {
        return this;
    }

    @Override
    public ITaskHunter.IMessageHandler getMessageHandler() {
        return mMessageHandler;
    }

    @Override
    public boolean is(int id) {
        return getId() == id;
    }

    @Override
    public boolean is(FileDownloadListener listener) {
        return getListener() == listener;
    }

    @Override
    public boolean isOver() {
        return FileDownloadStatus.isOver(getStatus());
    }

    @Override
    public int getAttachKey() {
        return mAttachKey;
    }

    @Override
    public void setAttachKeyByQueue(int key) {
        this.mAttachKey = key;
    }

    @Override
    public void setAttachKeyDefault() {
        final int attachKey;
        if (getListener() != null) {
            attachKey = getListener().hashCode();
        } else {
            attachKey = hashCode();
        }
        this.mAttachKey = attachKey;
    }

    @Override
    public String toString() {
        return FileDownloadUtils.formatString("%d@%s", getId(), super.toString());
    }

    private static final class InQueueTaskImpl implements InQueueTask {
        private final DownloadTask mTask;

        private InQueueTaskImpl(DownloadTask task) {
            this.mTask = task;
            this.mTask.mIsInQueueTask = true;
        }

        @Override
        public int enqueue() {
            final int id = mTask.getId();

            if (FileDownloadLog.NEED_LOG) {
                FileDownloadLog.d(this, "add the task[%d] to the queue", id);
            }

            FileDownloadList.getImpl().addUnchecked(mTask);
            return id;
        }
    }
}