/*
 * 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.SignatureVerificationFailedException;
import cc.arduino.contributions.SignatureVerifier;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.module.mrbean.MrBeanModule;
import org.apache.commons.compress.utils.IOUtils;
import processing.app.Platform;
import processing.app.PreferencesData;
import processing.app.debug.TargetPackage;
import processing.app.debug.TargetPlatform;
import processing.app.debug.TargetPlatformException;
import processing.app.helpers.FileUtils;
import processing.app.helpers.PreferencesMap;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;
import java.util.stream.Collectors;

import static processing.app.I18n.format;
import static processing.app.I18n.tr;
import static processing.app.helpers.filefilters.OnlyDirs.ONLY_DIRS;

public class ContributionsIndexer {

  private final File packagesFolder;
  private final File stagingFolder;
  private final File preferencesFolder;
  private final File builtInHardwareFolder;
  private final Platform platform;
  private final SignatureVerifier signatureVerifier;
  private final ContributionsIndex index;

  public ContributionsIndexer(File preferencesFolder, File builtInHardwareFolder, Platform platform, SignatureVerifier signatureVerifier) {
    this.preferencesFolder = preferencesFolder;
    this.builtInHardwareFolder = builtInHardwareFolder;
    this.platform = platform;
    this.signatureVerifier = signatureVerifier;
    index = new EmptyContributionIndex();
    packagesFolder = new File(preferencesFolder, "packages");
    stagingFolder = new File(preferencesFolder, "staging" + File.separator + "packages");
  }

  public void parseIndex() throws Exception {
    // Read bundled index...
    File bundledIndexFile = new File(builtInHardwareFolder, Constants.BUNDLED_INDEX_FILE_NAME);
    mergeContributions(bundledIndexFile);

    // ...and overlay the default index if present
    File defaultIndexFile = getIndexFile(Constants.DEFAULT_INDEX_FILE_NAME);
    if (defaultIndexFile.exists()) {
      // Check main index signature
      if (!PreferencesData.getBoolean("allow_insecure_packages") && !signatureVerifier.isSigned(defaultIndexFile)) {
        throw new SignatureVerificationFailedException(Constants.DEFAULT_INDEX_FILE_NAME);
      }

      mergeContributions(defaultIndexFile);
    }

    // Set main and bundled indexes as trusted
    index.getPackages().forEach(pack -> pack.setTrusted(true));

    // Overlay 3rd party indexes
    File[] indexFiles = preferencesFolder.listFiles(new TestPackageIndexFilenameFilter(new PackageIndexFilenameFilter(Constants.DEFAULT_INDEX_FILE_NAME)));

    if (indexFiles != null) {
      for (File indexFile : indexFiles) {
        try {
          mergeContributions(indexFile);
        } catch (JsonProcessingException e) {
          System.err.println(format(tr("Skipping contributed index file {0}, parsing error occured:"), indexFile));
          System.err.println(e);
        }
      }
    } else {
      System.err.println(format(tr("Error reading package indexes folder: {0}\n(maybe a permission problem?)"), preferencesFolder));
    }

    // Fill tools and toolsDependency cross references
    List<ContributedPackage> packages = index.getPackages();
    Collection<ContributedPackage> packagesWithTools = packages.stream()
      .filter(input -> input.getTools() != null && !input.getTools().isEmpty())
      .collect(Collectors.toList());

    for (ContributedPackage pack : packages) {
      // Fill references to package in tools
      for (ContributedTool tool : pack.getTools()) {
        tool.setPackage(pack);
      }

      for (ContributedPlatform plat : pack.getPlatforms()) {
        // Set a reference to parent packages
        plat.setParentPackage(pack);

        // Resolve tools dependencies (works also as a check for file integrity)
        plat.resolveToolsDependencies(packagesWithTools);
      }
    }

    index.fillCategories();
  }

  private void mergeContributions(File indexFile) throws IOException {
    if (!indexFile.exists())
      return;

    ContributionsIndex contributionsIndex = parseIndex(indexFile);
    boolean signed = signatureVerifier.isSigned(indexFile);
    boolean trustall = PreferencesData.getBoolean(Constants.PREF_CONTRIBUTIONS_TRUST_ALL);

    for (ContributedPackage contributedPackage : contributionsIndex.getPackages()) {
      contributedPackage.setTrusted(signed || trustall);
      if (!contributedPackage.isTrusted()) {
        for (ContributedPlatform contributedPlatform : contributedPackage.getPlatforms()) {
          contributedPlatform.setCategory("Contributed");
        }
      }

      ContributedPackage targetPackage = index.getPackage(contributedPackage.getName());

      if (targetPackage == null) {
        index.getPackages().add(contributedPackage);
      } else {
        if (contributedPackage.isTrusted() || !isPackageNameProtected(contributedPackage)) {
          if (isPackageNameProtected(contributedPackage) && trustall) {
            System.err.println(format(tr("Warning: forced trusting untrusted contributions")));
          }
          List<ContributedPlatform> platforms = contributedPackage.getPlatforms();
          if (platforms == null) {
            platforms = new LinkedList<>();
          }
          for (ContributedPlatform contributedPlatform : platforms) {
            ContributedPlatform plat = targetPackage.findPlatform(contributedPlatform.getArchitecture(), contributedPlatform.getVersion());
            if (plat != null) {
              targetPackage.getPlatforms().remove(plat);
            }
            targetPackage.getPlatforms().add(contributedPlatform);
          }
          List<ContributedTool> tools = contributedPackage.getTools();
          if (tools == null) {
            tools = new LinkedList<>();
          }
          for (ContributedTool contributedTool : tools) {
            ContributedTool tool = targetPackage.findTool(contributedTool.getName(), contributedTool.getVersion());
            if (tool != null) {
              targetPackage.getTools().remove(tool);
            }
            targetPackage.getTools().add(contributedTool);
          }
        }
      }
    }
  }

  private boolean isPackageNameProtected(ContributedPackage contributedPackage) {
    return Constants.PROTECTED_PACKAGE_NAMES.contains(contributedPackage.getName());
  }

  private ContributionsIndex parseIndex(File indexFile) throws IOException {
    InputStream inputStream = null;
    try {
      inputStream = new FileInputStream(indexFile);
      ObjectMapper mapper = new ObjectMapper();
      mapper.registerModule(new MrBeanModule());
      mapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true);
      mapper.configure(DeserializationFeature.EAGER_DESERIALIZER_FETCH, true);
      mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
      return mapper.readValue(inputStream, ContributionsIndex.class);
    } finally {
      IOUtils.closeQuietly(inputStream);
    }
  }

  public void syncWithFilesystem() throws IOException {
    syncBuiltInHardware();

    syncLocalPackages();
  }

  private void syncBuiltInHardware() throws IOException {
    if (index == null) {
      return;
    }
    for (File folder : builtInHardwareFolder.listFiles(ONLY_DIRS)) {
      ContributedPackage pack = index.findPackage(folder.getName());
      if (pack == null)
        continue;
      syncBuiltInPackageWithFilesystem(pack, folder);

      File toolsFolder = new File(builtInHardwareFolder, "tools");
      if (!toolsFolder.isDirectory())
        continue;

      for (File toolFolder : toolsFolder.listFiles(ONLY_DIRS)) {

        // builtin_tools_versions.txt contains tools versions in the format:
        // "PACKAGER.TOOL_NAME=TOOL_VERSION"
        // for example:
        // "arduino.avrdude=6.0.1-arduino5"

        File versionsFile = new File(toolFolder, "builtin_tools_versions.txt");
        if (!versionsFile.isFile())
          continue;
        PreferencesMap toolsVersion = new PreferencesMap(versionsFile).subTree(pack.getName());
        for (String name : toolsVersion.keySet()) {
          String version = toolsVersion.get(name);
          ContributedTool tool = syncToolWithFilesystem(pack, toolFolder, name, version);
          if (tool != null)
            tool.setBuiltIn(true);
        }
      }
    }
  }

  private void syncBuiltInPackageWithFilesystem(ContributedPackage pack, File hardwareFolder) throws IOException {
    // Scan all hardware folders and mark as installed all the tools found.
    for (File platformFolder : hardwareFolder.listFiles(ONLY_DIRS)) {
      File platformTxt = new File(platformFolder, "platform.txt");
      String version = new PreferencesMap(platformTxt).get("version");
      ContributedPlatform p = syncHardwareWithFilesystem(pack, platformFolder, platformFolder.getName(), version);
      if (p != null) {
        p.setBuiltIn(true);
      }
    }
  }

  private void syncLocalPackages() {
    if (!packagesFolder.isDirectory()) {
      return;
    }

    if (index == null) {
      return;
    }

    // Scan all hardware folders and mark as installed all the
    // platforms found.
    for (File folder : packagesFolder.listFiles(ONLY_DIRS)) {
      ContributedPackage pack = index.findPackage(folder.getName());
      if (pack != null) {
        syncPackageWithFilesystem(pack, folder);
      }
    }
  }

  private void syncPackageWithFilesystem(ContributedPackage pack, File root) {
    // Scan all hardware folders and mark as installed all the tools found.
    File hardwareFolder = new File(root, "hardware");
    if (hardwareFolder.isDirectory()) {
      for (File platformFolder : hardwareFolder.listFiles(ONLY_DIRS)) {
        for (File versionFolder : platformFolder.listFiles(ONLY_DIRS)) {
          syncHardwareWithFilesystem(pack, versionFolder, platformFolder.getName(), versionFolder.getName());
        }
      }
    }

    // Scan all tools folders and mark as installed all the tools found.
    File toolsFolder = new File(root, "tools");
    if (toolsFolder.isDirectory()) {
      for (File toolFolder : toolsFolder.listFiles(ONLY_DIRS)) {
        for (File versionFolder : toolFolder.listFiles(ONLY_DIRS)) {
          syncToolWithFilesystem(pack, versionFolder, toolFolder.getName(), versionFolder.getName());
        }
      }
    }
  }

  private ContributedTool syncToolWithFilesystem(ContributedPackage pack, File installationFolder, String toolName, String version) {
    ContributedTool tool = pack.findTool(toolName, version);
    if (tool == null) {
      tool = pack.findResolvedTool(toolName, version);
    }
    if (tool == null) {
      return null;
    }
    DownloadableContribution contrib = tool.getDownloadableContribution(platform);
    if (contrib == null) {
      System.err.println(tool + " seems to have no downloadable contributions for your operating system, but it is installed in\n" + installationFolder);
      return null;
    }
    tool.setInstalled(true);
    tool.setInstalledFolder(installationFolder);
    tool.setBuiltIn(false);
    return tool;
  }

  private ContributedPlatform syncHardwareWithFilesystem(ContributedPackage pack, File installationFolder, String architecture, String version) {
    ContributedPlatform p = pack.findPlatform(architecture, version);
    if (p != null) {
      p.setInstalled(true);
      p.setBuiltIn(false);
      p.setInstalledFolder(installationFolder);
    }
    return p;
  }

  @Override
  public String toString() {
    return index.toString();
  }

  public List<TargetPackage> createTargetPackages() {
    List<TargetPackage> packages = new ArrayList<>();

    if (index == null) {
      return packages;
    }

    for (ContributedPackage aPackage : index.getPackages()) {
      ContributedTargetPackage targetPackage = new ContributedTargetPackage(aPackage.getName());

      List<ContributedPlatform> platforms = aPackage.getPlatforms().stream() //
          .filter(p -> p.isInstalled()) //
          .collect(Collectors.toList());
      Collections.sort(platforms, ContributedPlatform.BUILTIN_AS_LAST);

      for (ContributedPlatform p : platforms) {
        String arch = p.getArchitecture();
        File folder = p.getInstalledFolder();

        try {
          TargetPlatform targetPlatform = new ContributedTargetPlatform(arch, folder, targetPackage);
          if (!targetPackage.hasPlatform(targetPlatform)) {
            targetPackage.addPlatform(targetPlatform);
          }
        } catch (TargetPlatformException e) {
          System.err.println(e.getMessage());
        }
      }

      if (targetPackage.hasPlatforms()) {
        packages.add(targetPackage);
      }
    }

    Collections.sort(packages, (package1, package2) -> {
      assert package1.getId() != null && package2.getId() != null;
      return package1.getId().toLowerCase().compareTo(package2.getId().toLowerCase());
    });

    return packages;
  }

  public boolean isContributedToolUsed(ContributedPlatform platformToIgnore, ContributedTool tool) {
    for (ContributedPackage pack : index.getPackages()) {
      for (ContributedPlatform p : pack.getPlatforms()) {
        if (platformToIgnore.equals(p)) {
          continue;
        }
        if (!p.isInstalled() || p.isBuiltIn()) {
          continue;
        }
        for (ContributedTool requiredTool : p.getResolvedTools()) {
          if (requiredTool.equals(tool))
            return true;
        }
      }
    }
    return false;
  }

  public Set<ContributedTool> getInstalledTools() {
    Set<ContributedTool> tools = new HashSet<>();
    if (index == null) {
      return tools;
    }
    for (ContributedPackage pack : index.getPackages()) {
      Collection<ContributedPlatform> platforms = pack.getPlatforms().stream() //
          .filter(p -> p.isInstalled()) //
          .collect(Collectors.toList());
      Map<String, List<ContributedPlatform>> platformsByName = platforms.stream().collect(Collectors.groupingBy(ContributedPlatform::getName));

      platformsByName.forEach((platformName, platformsWithName) -> {
        if (platformsWithName.size() > 1) {
          platformsWithName = platformsWithName.stream() //
              .filter(p -> !p.isBuiltIn()) //
              .collect(Collectors.toList());
        }
        for (ContributedPlatform p : platformsWithName) {
          tools.addAll(p.getResolvedTools());
        }
      });
    }
    return tools;
  }

  public ContributionsIndex getIndex() {
    return index;
  }

  public File getPackagesFolder() {
    return packagesFolder;
  }

  public File getStagingFolder() {
    return stagingFolder;
  }

  public File getIndexFile(String name) {
    return new File(preferencesFolder, name);
  }

  public List<ContributedPackage> getPackages() {
    if (index == null) {
      return new LinkedList<>();
    }
    return index.getPackages();
  }

  public List<String> getCategories() {
    if (index == null) {
      return new LinkedList<>();
    }
    return index.getCategories();
  }

  public ContributedPlatform getInstalled(String packageName, String platformArch) {
    if (index == null) {
      return null;
    }
    return index.getInstalledPlatform(packageName, platformArch);
  }

  private List<ContributedPlatform> getInstalledPlatforms() {
    if (index == null) {
      return new LinkedList<>();
    }
    return index.getInstalledPlatforms();
  }

  public Optional<ContributedPlatform> getPlatformByFolder(final File folder) {
    return getInstalledPlatforms().stream().filter(contributedPlatform -> {
      assert contributedPlatform.getInstalledFolder() != null;
      return FileUtils.isSubDirectory(contributedPlatform.getInstalledFolder(), folder);
    }).findFirst();
  }

  public ContributedPlatform getContributedPlaform(TargetPlatform targetPlatform) {
    for (ContributedPlatform plat : getInstalledPlatforms()) {
      if (plat.getInstalledFolder().equals(targetPlatform.getFolder()))
        return plat;
    }
    return null;
  }
}
