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

import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.repository.Revision;
import com.android.repository.api.Dependency;
import com.android.repository.api.License;
import com.android.repository.api.LocalPackage;
import com.android.repository.api.PackageOperation;
import com.android.repository.api.ProgressIndicator;
import com.android.repository.api.RemotePackage;
import com.android.repository.api.RepoManager;
import com.android.repository.api.RepoPackage;
import com.android.repository.api.Repository;
import com.android.repository.api.RepositorySource;
import com.android.repository.api.UpdatablePackage;
import com.android.repository.impl.manager.LocalRepoLoaderImpl;
import com.android.repository.impl.meta.Archive;
import com.android.repository.impl.meta.CommonFactory;
import com.android.repository.impl.meta.LocalPackageImpl;
import com.android.repository.impl.meta.RepositoryPackages;
import com.android.repository.impl.meta.RevisionType;
import com.android.repository.impl.meta.SchemaModuleUtil;
import com.android.repository.io.FileOp;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import com.google.common.collect.Sets;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.Enumeration;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import javax.xml.bind.JAXBElement;
import javax.xml.bind.JAXBException;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipFile;

/**
 * Utility methods for {@link PackageOperation} implementations.
 */
public class InstallerUtil {

    /**
     * The name of the package metadata file for a package in the process of being installed.
     */
    public static final String PENDING_PACKAGE_XML_FN = "package.xml.pending";
    public static final String INSTALLER_DIR_FN = ".installer";

    /**
     * Unzips the given zipped input stream into the given directory.
     *
     * @param in           The (zipped) input stream.
     * @param out          The directory into which to expand the files. Must exist.
     * @param fop          The {@link FileOp} to use for file operations.
     * @param expectedSize Compressed size of the stream.
     * @param progress     Currently only used for logging.
     * @throws IOException If we're unable to read or write.
     */
    public static void unzip(@NonNull File in, @NonNull File out, @NonNull FileOp fop,
            long expectedSize, @NonNull ProgressIndicator progress)
            throws IOException {
        if (!fop.exists(out) || !fop.isDirectory(out)) {
            throw new IllegalArgumentException("out must exist and be a directory.");
        }
        // ZipFile requires an actual (not mock) file, so make sure we have a real one.
        in = fop.ensureRealFile(in);

        progress.setText("Unzipping...");
        ZipFile zipFile = new ZipFile(in);
        try {
            Enumeration entries = zipFile.getEntries();
            progress.setFraction(0);
            while (entries.hasMoreElements()) {
                ZipArchiveEntry entry = (ZipArchiveEntry) entries.nextElement();
                String name = entry.getName();
                File entryFile = new File(out, name);
                progress.setSecondaryText(name);
                if (entry.isUnixSymlink()) {
                    ByteArrayOutputStream targetByteStream = new ByteArrayOutputStream();
                    readZipEntry(zipFile, entry, targetByteStream, expectedSize, progress);
                    Path linkPath = fop.toPath(entryFile);
                    Path linkTarget = fop.toPath(new File(targetByteStream.toString()));
                    Files.createSymbolicLink(linkPath, linkTarget);
                } else if (entry.isDirectory()) {
                    if (!fop.exists(entryFile)) {
                        if (!fop.mkdirs(entryFile)) {
                            progress.logWarning("failed to mkdirs " + entryFile);
                        }
                    }
                } else {
                    if (!fop.exists(entryFile)) {
                        File parent = entryFile.getParentFile();
                        if (parent != null && !fop.exists(parent)) {
                            fop.mkdirs(parent);
                        }
                        if (!fop.createNewFile(entryFile)) {
                            throw new IOException("Failed to create file " + entryFile);
                        }
                    }

                    OutputStream unzippedOutput = fop.newFileOutputStream(entryFile);
                    if (readZipEntry(zipFile, entry, unzippedOutput, expectedSize, progress)) {
                        return;
                    }
                    if (!fop.isWindows()) {
                        // get the mode and test if it contains the executable bit
                        int mode = entry.getUnixMode();
                        //noinspection OctalInteger
                        if ((mode & 0111) != 0) {
                            try {
                                fop.setExecutablePermission(entryFile);
                            } catch (IOException ignore) {
                            }
                        }
                    }
                }
            }
        }
        finally {
            ZipFile.closeQuietly(zipFile);
        }
    }

    private static boolean readZipEntry(ZipFile zipFile, ZipArchiveEntry entry, OutputStream dest,
            long expectedSize, @NonNull ProgressIndicator progress) throws IOException {
        int size;
        byte[] buf = new byte[8192];
        double fraction = progress.getFraction();
        try (BufferedOutputStream bufferedDest = new BufferedOutputStream(dest);
             InputStream s = new BufferedInputStream(zipFile.getInputStream(entry))) {
            while ((size = s.read(buf)) > -1) {
                bufferedDest.write(buf, 0, size);
                fraction += ((double) entry.getCompressedSize() / expectedSize) *
                        ((double) size / entry.getSize());
                progress.setFraction(fraction);
                if (progress.isCanceled()) {
                    return true;
                }
            }
        }
        return false;
    }

    public static void writePendingPackageXml(@NonNull RepoPackage p, @NonNull File packageRoot,
            @NonNull RepoManager manager, @NonNull FileOp fop, @NonNull ProgressIndicator progress)
            throws IOException {
        if (!fop.exists(packageRoot) || !fop.isDirectory(packageRoot)) {
            throw new IllegalArgumentException("packageRoot must exist and be a directory.");
        }
        CommonFactory factory = p.createFactory();
        // Create the package.xml
        Repository repo = factory.createRepositoryType();
        License license = p.getLicense();
        if (license != null) {
            repo.addLicense(license);
        }

        p.asMarshallable().addTo(repo);

        File packageXml = new File(packageRoot, PENDING_PACKAGE_XML_FN);
        writeRepoXml(manager, repo, packageXml, fop, progress);
    }

    @Nullable
    public static Repository readPendingPackageXml(@NonNull File containingDir,
            @NonNull RepoManager manager, @NonNull FileOp fop,
            @NonNull ProgressIndicator progress) throws IOException {
        Repository repo;
        try {
            File xmlFile = new File(containingDir, PENDING_PACKAGE_XML_FN);
            if (!fop.exists(xmlFile)) {
                return null;
            }
            repo = (Repository) SchemaModuleUtil.unmarshal(
                    fop.newFileInputStream(xmlFile),
                    manager.getSchemaModules(), manager.getResourceResolver(progress), false,
                    progress);
        } catch (JAXBException e) {
            throw new IOException("Failed to parse pending package xml", e);
        }
        return repo;
    }

    /**
     * Writes out the XML for a {@link LocalPackageImpl} corresponding to the given {@link
     * RemotePackage} to a {@code package.xml} file in {@code packageRoot}.
     *
     * @param p           The package to convert to a local package and write out.
     * @param packageRoot The location to write to. Must exist and be a directory.
     * @param manager     A {@link RepoManager} instance.
     * @param fop         The {@link FileOp} to use for file operations.
     * @param progress    Currently only used for logging.
     * @throws IOException If we fail to write the output file.
     */
    public static void writePackageXml(@NonNull RemotePackage p, @NonNull File packageRoot,
            @NonNull RepoManager manager, @NonNull FileOp fop, @NonNull ProgressIndicator progress)
            throws IOException {
        if (!fop.exists(packageRoot) || !fop.isDirectory(packageRoot)) {
            throw new IllegalArgumentException("packageRoot must exist and be a directory.");
        }
        CommonFactory factory = (CommonFactory) RepoManager.getCommonModule().createLatestFactory();
        // Create the package.xml
        Repository repo = factory.createRepositoryType();
        License l = p.getLicense();
        if (l != null) {
            repo.addLicense(l);
        }
        LocalPackageImpl impl = LocalPackageImpl.create(p);
        repo.setLocalPackage(impl);
        File packageXml = new File(packageRoot, LocalRepoLoaderImpl.PACKAGE_XML_FN);
        writeRepoXml(manager, repo, packageXml, fop, progress);
    }

    public static void writeRepoXml(@NonNull RepoManager manager,
            @NonNull Repository repo, @NonNull File packageXml, @NonNull FileOp fop,
            @NonNull ProgressIndicator progress) throws IOException {
        OutputStream fos = fop.newFileOutputStream(packageXml);
        JAXBElement<Repository> element = ((CommonFactory) RepoManager.getCommonModule()
                .createLatestFactory()).generateRepository(repo);
        try {
            SchemaModuleUtil.marshal(element, manager.getSchemaModules(), fos,
                    manager.getResourceResolver(progress), progress);
        } finally {
            fos.close();
        }
    }

    /**
     * Returns a URL corresponding to {@link Archive#getComplete()} of the given {@link
     * RemotePackage}. If the url in the package is a relative url, resolves it by using the prefix
     * of the url in the {@link RepositorySource} of the package.
     *
     * @return The resolved {@link URL}, or {@code null} if the given archive location is not
     * parsable in its original or resolved form.
     */
    @Nullable
    public static URL resolveCompleteArchiveUrl(@NonNull RemotePackage p,
            @NonNull ProgressIndicator progress) {
        Archive arch = p.getArchive();
        if (arch == null) {
            return null;
        }
        String urlStr = arch.getComplete().getUrl();
        return resolveUrl(urlStr, p, progress);
    }

    @Nullable
    public static URL resolveUrl(@NonNull String urlStr, @NonNull RemotePackage p,
            @NonNull ProgressIndicator progress) {
        URL url;
        try {
            url = new URL(urlStr);
        } catch (MalformedURLException e) {
            // If we don't have a real URL, it could be a relative URL. Pick up the URL prefix
            // from the source.
            try {
                String sourceUrl = p.getSource().getUrl();
                if (!sourceUrl.endsWith("/")) {
                    sourceUrl = sourceUrl.substring(0, sourceUrl.lastIndexOf('/') + 1);
                }
                urlStr = sourceUrl + urlStr;
                url = new URL(urlStr);
            } catch (MalformedURLException e2) {
                progress.logWarning("Failed to parse url: " + urlStr);
                return null;
            }
        }
        return url;
    }

    /**
     * Compute the complete list of packages that need to be installed to meet the dependencies of
     * the given list (including the requested packages themselves, if they are not already
     * installed). Returns {@code null} if we were unable to compute a complete list of dependencies
     * due to not being able to find required packages of the specified version.
     *
     * Packages are returned in install order (that is, if we request A which depends on B, the
     * result will be [B, A]). If a dependency cycle is encountered the order of the returned
     * results at or below the cycle is undefined. For example if we have A -> [B, C], B -> D, and D
     * -> B then the returned list will be either [B, D, C, A] or [D, B, C, A].
     *
     * Note that we assume installed packages already have their dependencies met.
     */
    @Nullable
    public static List<RemotePackage> computeRequiredPackages(
            @NonNull Collection<RemotePackage> requests, @NonNull RepositoryPackages packages,
            @NonNull ProgressIndicator logger) {
        Set<RemotePackage> requiredPackages = Sets.newHashSet();
        Map<String, UpdatablePackage> consolidatedPackages = packages.getConsolidatedPkgs();

        Set<String> seen = Sets.newHashSet();
        Multimap<String, Dependency> allDependencies = HashMultimap.create();
        Set<RemotePackage> roots = Sets.newHashSet();
        Queue<RemotePackage> current = Lists.newLinkedList();
        for (RemotePackage request : requests) {
            UpdatablePackage updatable = consolidatedPackages.get(request.getPath());
            if (updatable == null) {
                logger.logWarning(String.format("No package with key %s found!", request.getPath()));
                return null;
            }
            if (!updatable.hasLocal() || updatable.isUpdate()) {
                current.add(request);
                roots.add(request);
                requiredPackages.add(request);
                seen.add(request.getPath());
            }
        }

        while (!current.isEmpty()) {
            RemotePackage currentPackage = current.remove();

            Collection<Dependency> currentDependencies = currentPackage.getAllDependencies();
            for (Dependency d : currentDependencies) {
                String dependencyPath = d.getPath();
                UpdatablePackage updatableDependency = consolidatedPackages.get(dependencyPath);
                if (updatableDependency == null) {
                    logger.logWarning(
                            String.format("Dependant package with key %s not found!",
                                    dependencyPath));
                    return null;
                }
                LocalPackage localDependency = updatableDependency.getLocal();
                Revision requiredMinRevision = null;
                RevisionType r = d.getMinRevision();
                if (r != null) {
                    requiredMinRevision = r.toRevision();
                }
                if (localDependency != null && (requiredMinRevision == null ||
                        requiredMinRevision.compareTo(localDependency.getVersion()) <= 0)) {
                    continue;
                }
                if (seen.contains(dependencyPath)) {
                    allDependencies.put(dependencyPath, d);
                    continue;
                }
                seen.add(dependencyPath);
                RemotePackage remoteDependency = updatableDependency.getRemote();
                if (remoteDependency == null || (requiredMinRevision != null
                        && requiredMinRevision.compareTo(remoteDependency.getVersion()) > 0)) {
                    logger.logWarning(String
                            .format("Package \"%1$s\" with revision at least %2$s not available.",
                                    updatableDependency.getRepresentative().getDisplayName(),
                                    requiredMinRevision));
                    return null;
                }

                requiredPackages.add(remoteDependency);
                allDependencies.put(dependencyPath, d);
                current.add(remoteDependency);
                // We had a dependency on it, so it can't be a root.
                roots.remove(remoteDependency);
            }
        }

        List<RemotePackage> result = Lists.newArrayList();

        while (!roots.isEmpty()) {
            RemotePackage root = roots.iterator().next();
            roots.remove(root);
            result.add(root);
            for (Dependency d : root.getAllDependencies()) {
                Collection<Dependency> nodeDeps = allDependencies.get(d.getPath());
                if (nodeDeps.size() == 1) {
                    UpdatablePackage newRoot = consolidatedPackages.get(d.getPath());
                    if (newRoot == null) {
                        logger.logWarning(
                                String.format("Package with key %s not found!", d.getPath()));
                        return null;
                    }

                    roots.add(newRoot.getRemote());
                }
                nodeDeps.remove(d);
            }
        }

        if (result.size() != requiredPackages.size()) {
            logger.logInfo("Failed to sort dependencies, returning partially-sorted list.");
            for (RemotePackage p : result) {
                requiredPackages.remove(p);
            }
            result.addAll(requiredPackages);
        }

        return Lists.reverse(result);
    }

    /**
     * Checks to see whether {@code path} is a valid install path. Specifically, checks whether
     * there are any existing packages installed in parents or children of {@code path}. Returns
     * {@code true} if the path is valid. Otherwise returns {@code false} and logs a warning.
     */
    public static boolean checkValidPath(@NonNull File path, @NonNull RepoManager manager,
            @NonNull ProgressIndicator progress) {
        try {
            String check = path.getCanonicalPath() + File.separator;

            for (LocalPackage p : manager.getPackages().getLocalPackages().values()) {
                String existing = p.getLocation().getCanonicalPath() + File.separator;
                if (!existing.equals(check)) {
                    boolean childExists = existing.startsWith(check);
                    boolean parentExists = check.startsWith(existing);
                    if (childExists || parentExists) {
                        StringBuilder message = new StringBuilder();
                        message.append("Trying to install into ")
                                .append(check)
                                .append(" but package \"")
                                .append(p.getDisplayName())
                                .append("\" already exists at ")
                                .append(existing)
                                .append(". It must be deleted or moved away before installing into a ")
                                .append(childExists ? "parent" : "child")
                                .append(" directory.");
                        progress.logWarning(message.toString());
                        return false;
                    }
                }
            }
        } catch (IOException e) {
            progress.logWarning("Error while trying to check install path validity", e);
            return false;
        }

        return true;
    }
}
