/*
 * This file is part of Arduino.
 *
 * Copyright 2014 Arduino LLC (http://www.arduino.cc/)
 *
 * Arduino is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
 *
 * As a special exception, you may use this file as part of a free software
 * library without restriction.  Specifically, if other files instantiate
 * templates or use macros or inline functions from this file, or you compile
 * this file and link it with other files to produce an executable, this
 * file does not by itself cause the resulting executable to be covered by
 * the GNU General Public License.  This exception does not however
 * invalidate any other reasons why the executable file might be covered by
 * the GNU General Public License.
 */

package cc.arduino.contributions.packages;

import cc.arduino.Constants;
import cc.arduino.contributions.DownloadableContribution;
import cc.arduino.contributions.DownloadableContributionsDownloader;
import cc.arduino.contributions.ProgressListener;
import cc.arduino.contributions.SignatureVerifier;
import cc.arduino.filters.FileExecutablePredicate;
import cc.arduino.utils.ArchiveExtractor;
import cc.arduino.utils.MultiStepProgress;
import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecutor;
import org.apache.commons.exec.Executor;
import org.apache.commons.exec.PumpStreamHandler;
import processing.app.BaseNoGui;
import processing.app.I18n;
import processing.app.Platform;
import processing.app.PreferencesData;
import processing.app.helpers.FileUtils;
import processing.app.helpers.filefilters.OnlyDirs;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Collectors;

import static processing.app.I18n.format;
import static processing.app.I18n.tr;

public class ContributionInstaller {

  private final Platform platform;
  private final SignatureVerifier signatureVerifier;

  public ContributionInstaller(Platform platform, SignatureVerifier signatureVerifier) {
    this.platform = platform;
    this.signatureVerifier = signatureVerifier;
  }

  public synchronized List<String> install(ContributedPlatform contributedPlatform, ProgressListener progressListener) throws Exception {
    List<String> errors = new LinkedList<>();
    if (contributedPlatform.isInstalled()) {
      throw new Exception("Platform is already installed!");
    }

    // Do not download already installed tools
    List<ContributedTool> tools = new ArrayList<>();
    for (ContributedTool tool : contributedPlatform.getResolvedTools()) {
      DownloadableContribution downloadable = tool.getDownloadableContribution(platform);
      if (downloadable == null) {
        throw new Exception(format(tr("Tool {0} is not available for your operating system."), tool.getName()));
      }
      // Download the tool if it's not installed or it's a built-in tool
      if (!tool.isInstalled() || tool.isBuiltIn()) {
        tools.add(tool);
      }
    }

    DownloadableContributionsDownloader downloader = new DownloadableContributionsDownloader(BaseNoGui.indexer.getStagingFolder());

    // Calculate progress increases
    MultiStepProgress progress = new MultiStepProgress((tools.size() + 1) * 2);

    // Download all
    try {
      // Download platform
      downloader.download(contributedPlatform, progress, tr("Downloading boards definitions."), progressListener);
      progress.stepDone();

      // Download tools
      int i = 1;
      for (ContributedTool tool : tools) {
        String msg = format(tr("Downloading tools ({0}/{1})."), i, tools.size());
        i++;
        downloader.download(tool.getDownloadableContribution(platform), progress, msg, progressListener);
        progress.stepDone();
      }
    } catch (InterruptedException e) {
      // Download interrupted... just exit
      return errors;
    }

    ContributedPackage pack = contributedPlatform.getParentPackage();
    File packageFolder = new File(BaseNoGui.indexer.getPackagesFolder(), pack.getName());

    // TODO: Extract to temporary folders and move to the final destination only
    // once everything is successfully unpacked. If the operation fails remove
    // all the temporary folders and abort installation.

    List<Map.Entry<ContributedToolReference, ContributedTool>> resolvedToolReferences = contributedPlatform
        .getResolvedToolReferences().entrySet().stream()
        .filter((entry) -> !entry.getValue().isInstalled()
                           || entry.getValue().isBuiltIn())
        .collect(Collectors.toList());

    int i = 1;
    for (Map.Entry<ContributedToolReference, ContributedTool> entry : resolvedToolReferences) {
      progress.setStatus(format(tr("Installing tools ({0}/{1})..."), i, resolvedToolReferences.size()));
      progressListener.onProgress(progress);
      i++;
      ContributedTool tool = entry.getValue();
      Path destFolder = Paths.get(BaseNoGui.indexer.getPackagesFolder().getAbsolutePath(), entry.getKey().getPackager(), "tools", tool.getName(), tool.getVersion());

      Files.createDirectories(destFolder);

      DownloadableContribution toolContrib = tool.getDownloadableContribution(platform);
      assert toolContrib.getDownloadedFile() != null;
      new ArchiveExtractor(platform).extract(toolContrib.getDownloadedFile(), destFolder.toFile(), 1);
      try {
        findAndExecutePostInstallScriptIfAny(destFolder.toFile(), contributedPlatform.getParentPackage().isTrusted(), PreferencesData.getBoolean(Constants.PREF_CONTRIBUTIONS_TRUST_ALL));
      } catch (IOException e) {
        errors.add(tr("Error running post install script"));
      }
      tool.setInstalled(true);
      tool.setInstalledFolder(destFolder.toFile());
      progress.stepDone();
    }

    // Unpack platform on the correct location
    progress.setStatus(tr("Installing boards..."));
    progressListener.onProgress(progress);
    File platformFolder = new File(packageFolder, "hardware" + File.separator + contributedPlatform.getArchitecture());
    File destFolder = new File(platformFolder, contributedPlatform.getParsedVersion());
    Files.createDirectories(destFolder.toPath());
    new ArchiveExtractor(platform).extract(contributedPlatform.getDownloadedFile(), destFolder, 1);
    contributedPlatform.setInstalled(true);
    contributedPlatform.setInstalledFolder(destFolder);
    try {
      findAndExecutePostInstallScriptIfAny(destFolder, contributedPlatform.getParentPackage().isTrusted(), PreferencesData.getBoolean(Constants.PREF_CONTRIBUTIONS_TRUST_ALL));
    } catch (IOException e) {
      e.printStackTrace();
      errors.add(tr("Error running post install script"));
    }
    progress.stepDone();

    progress.setStatus(tr("Installation completed!"));
    progressListener.onProgress(progress);

    return errors;
  }

  private void findAndExecutePostInstallScriptIfAny(File folder, boolean trusted, boolean trustAll) throws IOException {
    Collection<File> scripts = platform.postInstallScripts(folder).stream().filter(new FileExecutablePredicate()).collect(Collectors.toList());

    if (scripts.isEmpty()) {
      String[] subfolders = folder.list(new OnlyDirs());
      if (subfolders.length != 1) {
        return;
      }

      findAndExecutePostInstallScriptIfAny(new File(folder, subfolders[0]), trusted, trustAll);
      return;
    }

    executeScripts(folder, scripts, trusted, trustAll);
  }

  private void findAndExecutePreUninstallScriptIfAny(File folder, boolean trusted, boolean trustAll) throws IOException {
    Collection<File> scripts = platform.preUninstallScripts(folder).stream().filter(new FileExecutablePredicate()).collect(Collectors.toList());

    if (scripts.isEmpty()) {
      String[] subfolders = folder.list(new OnlyDirs());
      if (subfolders.length != 1) {
        return;
      }

      findAndExecutePreUninstallScriptIfAny(new File(folder, subfolders[0]), trusted, trustAll);
      return;
    }

    executeScripts(folder, scripts, trusted, trustAll);
  }

  private void executeScripts(File folder, Collection<File> postInstallScripts, boolean trusted, boolean trustAll) throws IOException {
    File script = postInstallScripts.iterator().next();

    if (!trusted && !trustAll) {
      System.err.println(I18n.format(tr("Warning: non trusted contribution, skipping script execution ({0})"), script));
      return;
    }

    if (trustAll) {
      System.err.println(I18n.format(tr("Warning: forced untrusted script execution ({0})"), script));
    }

    ByteArrayOutputStream stdout = new ByteArrayOutputStream();
    ByteArrayOutputStream stderr = new ByteArrayOutputStream();
    Executor executor = new DefaultExecutor();
    executor.setStreamHandler(new PumpStreamHandler(stdout, stderr));
    executor.setWorkingDirectory(folder);
    executor.setExitValues(null);
    int exitValue = executor.execute(new CommandLine(script));
    executor.setExitValues(new int[0]);

    System.out.write(stdout.toByteArray());
    System.err.write(stderr.toByteArray());

    if (executor.isFailure(exitValue)) {
      throw new IOException();
    }
  }

  public synchronized List<String> remove(ContributedPlatform contributedPlatform) {
    if (contributedPlatform == null || contributedPlatform.isBuiltIn()) {
      return new LinkedList<>();
    }
    List<String> errors = new LinkedList<>();
    try {
      findAndExecutePreUninstallScriptIfAny(contributedPlatform.getInstalledFolder(), contributedPlatform.getParentPackage().isTrusted(), PreferencesData.getBoolean(Constants.PREF_CONTRIBUTIONS_TRUST_ALL));
    } catch (IOException e) {
      errors.add(tr("Error running post install script"));
    }

    // Check if the tools are no longer needed
    for (ContributedTool tool : contributedPlatform.getResolvedTools()) {
      // Do not remove used tools
      if (BaseNoGui.indexer.isContributedToolUsed(contributedPlatform, tool))
        continue;

      // Do not remove built-in tools
      if (tool.isBuiltIn())
        continue;

      // Ok, delete the tool
      File destFolder = tool.getInstalledFolder();
      FileUtils.recursiveDelete(destFolder);
      tool.setInstalled(false);
      tool.setInstalledFolder(null);

      // We removed the version folder (.../tools/TOOL_NAME/VERSION)
      // now try to remove the containing TOOL_NAME folder
      // (and silently fail if another version of the tool is installed)
      try {
        Files.delete(destFolder.getParentFile().toPath());
      } catch (Exception e) {
        // ignore
      }
    }

    FileUtils.recursiveDelete(contributedPlatform.getInstalledFolder());
    contributedPlatform.setInstalled(false);
    contributedPlatform.setInstalledFolder(null);

    return errors;
  }

  public synchronized List<String> updateIndex(ProgressListener progressListener) throws Exception {
    MultiStepProgress progress = new MultiStepProgress(1);

    List<String> downloadedPackageIndexFilesAccumulator = new LinkedList<>();
    downloadIndexAndSignature(progress, downloadedPackageIndexFilesAccumulator, Constants.PACKAGE_INDEX_URL, progressListener);

    Set<String> packageIndexURLs = new HashSet<>();
    String additionalURLs = PreferencesData.get(Constants.PREF_BOARDS_MANAGER_ADDITIONAL_URLS, "");
    if (!"".equals(additionalURLs)) {
      packageIndexURLs.addAll(Arrays.asList(additionalURLs.split(",")));
    }

    for (String packageIndexURL : packageIndexURLs) {
      try {
        downloadIndexAndSignature(progress, downloadedPackageIndexFilesAccumulator, packageIndexURL, progressListener);
      } catch (Exception e) {
        System.err.println(e.getMessage());
      }
    }

    progress.stepDone();

    return downloadedPackageIndexFilesAccumulator;
  }

  private void downloadIndexAndSignature(MultiStepProgress progress, List<String> downloadedPackagedIndexFilesAccumulator, String packageIndexUrl, ProgressListener progressListener) throws Exception {
    File packageIndex = download(progress, packageIndexUrl, progressListener);
    downloadedPackagedIndexFilesAccumulator.add(packageIndex.getName());
    try {
      File packageIndexSignature = download(progress, packageIndexUrl + ".sig", progressListener);
      boolean signatureVerified = signatureVerifier.isSigned(packageIndex);
      if (signatureVerified) {
        downloadedPackagedIndexFilesAccumulator.add(packageIndexSignature.getName());
      } else {
        downloadedPackagedIndexFilesAccumulator.remove(packageIndex.getName());
        Files.delete(packageIndex.toPath());
        Files.delete(packageIndexSignature.toPath());
        System.err.println(I18n.format(tr("{0} file signature verification failed. File ignored."), packageIndexUrl));
      }
    } catch (Exception e) {
      //ignore errors
    }
  }

  private File download(MultiStepProgress progress, String packageIndexUrl, ProgressListener progressListener) throws Exception {
    String statusText = tr("Downloading platforms index...");
    URL url = new URL(packageIndexUrl);
    String[] urlPathParts = url.getFile().split("/");
    File outputFile = BaseNoGui.indexer.getIndexFile(urlPathParts[urlPathParts.length - 1]);
    File tmpFile = new File(outputFile.getAbsolutePath() + ".tmp");
    DownloadableContributionsDownloader downloader = new DownloadableContributionsDownloader(BaseNoGui.indexer.getStagingFolder());
    boolean noResume = true;
    downloader.download(url, tmpFile, progress, statusText, progressListener, noResume);

    Files.deleteIfExists(outputFile.toPath());
    Files.move(tmpFile.toPath(), outputFile.toPath());

    return outputFile;
  }

  public synchronized void deleteUnknownFiles(List<String> downloadedPackageIndexFiles) throws IOException {
    File preferencesFolder = BaseNoGui.indexer.getIndexFile(".").getParentFile();
    File[] additionalPackageIndexFiles = preferencesFolder.listFiles(new PackageIndexFilenameFilter(Constants.DEFAULT_INDEX_FILE_NAME));
    if (additionalPackageIndexFiles == null) {
      return;
    }
    for (File additionalPackageIndexFile : additionalPackageIndexFiles) {
      if (!downloadedPackageIndexFiles.contains(additionalPackageIndexFile.getName())) {
        Files.delete(additionalPackageIndexFile.toPath());
      }
    }
  }
}
