/* -*- mode: java; c-basic-offset: 2; indent-tabs-mode: nil -*- */

/*
  Part of the Processing project - http://processing.org

  Copyright (c) 2013-20 The Processing Foundation
  Copyright (c) 2011-12 Ben Fry and Casey Reas

  This program is free software; you can redistribute it and/or modify
  it under the terms of the GNU General Public License version 2
  as published by the Free Software Foundation.

  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.
  59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
*/
package processing.app.contrib;

import java.awt.EventQueue;
import java.io.*;
import java.lang.reflect.InvocationTargetException;
import java.net.URL;
import java.net.URLClassLoader;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.zip.*;

import javax.swing.JOptionPane;

import processing.app.*;
import processing.app.ui.Editor;
import processing.core.PApplet;
import processing.data.StringDict;
import processing.data.StringList;


/**
 * A contribution that has been downloaded to the disk, and may or may not
 * be installed.
 */
public abstract class LocalContribution extends Contribution {
  static public final String DELETION_FLAG = "marked_for_deletion";
  static public final String UPDATE_FLAGGED = "marked_for_update";
  static public final String RESTART_FLAG = "requires_restart";

  protected String id;          // 1 (unique id for this library)
  protected int latestVersion;  // 103
  protected File folder;
  protected StringDict properties;
  protected ClassLoader loader;


  public LocalContribution(File folder) {
    this.folder = folder;

    // required for contributed modes, but not for built-in core modes
    File propertiesFile = new File(folder, getTypeName() + ".properties");
    if (propertiesFile.exists()) {
      properties = Util.readSettings(propertiesFile);

      name = properties.get("name");
      id = properties.get("id");
      categories = parseCategories(properties);
      imports = parseImports(properties);
      if (name == null) {
        name = folder.getName();
      }
      // changing to 'authors' in 3.0a11
      authors = properties.get(AUTHORS_PROPERTY);
//      if (authors == null) {
//        authors = properties.get("authorList");
//      }
      url = properties.get("url");
      sentence = properties.get("sentence");
      paragraph = properties.get("paragraph");

      try {
        version = Integer.parseInt(properties.get("version"));
      } catch (NumberFormatException e) {
        System.err.println("The version number for the “" + name + "” library is not a number.");
        System.err.println("Please contact the library author to fix it according to the guidelines.");
      }

      setPrettyVersion(properties.get("prettyVersion"));

      try {
        lastUpdated = Long.parseLong(properties.get("lastUpdated"));
      } catch (NumberFormatException e) {
        lastUpdated = 0;

      // Better comment these out till all contribs have a lastUpdated
//        System.err.println("The last updated timestamp for the “" + name + "” library is not set properly.");
//        System.err.println("Please contact the library author to fix it according to the guidelines.");
      }

      String minRev = properties.get("minRevision");
      if (minRev != null) {
        minRevision = PApplet.parseInt(minRev, 0);
      }

      String maxRev = properties.get("maxRevision");
      if (maxRev != null) {
        maxRevision = PApplet.parseInt(maxRev, 0);
      }

    } else {
      Messages.log("No properties file at " + propertiesFile.getAbsolutePath());
      // We'll need this to be set at a minimum.
      name = folder.getName();
      categories = unknownCategoryList();
    }

    if (categories.hasValue(SPECIAL_CATEGORY)) {
      validateSpecial();
    }
  }


  private void validateSpecial() {
    for (AvailableContribution available : ContributionListing.getInstance().advertisedContributions) {
      if (available.getName().equals(name)) {
        if (!available.isSpecial()) {
          categories.removeValue(SPECIAL_CATEGORY);
        }
      }
      break;
    }
  }


  public String initLoader(String className) throws Exception {
    File modeDirectory = new File(folder, getTypeName());
    if (modeDirectory.exists()) {
      Messages.log("checking mode folder regarding " + className);
      // If no class name specified, search the main <modename>.jar for the
      // full name package and mode name.
      if (className == null) {
        String shortName = folder.getName();
        File mainJar = new File(modeDirectory, shortName + ".jar");
        if (mainJar.exists()) {
          className = findClassInZipFile(shortName, mainJar);
        } else {
          throw new IgnorableException(mainJar.getAbsolutePath() + " does not exist.");
        }

        if (className == null) {
          throw new IgnorableException("Could not find " + shortName +
                                       " class inside " + mainJar.getAbsolutePath());
        }
      }

      // Add .jar and .zip files from the "mode" folder into the classpath
      File[] archives = Util.listJarFiles(modeDirectory);
      if (archives != null && archives.length > 0) {
        URL[] urlList = new URL[archives.length];
        for (int j = 0; j < urlList.length; j++) {
          Messages.log("Found archive " + archives[j] + " for " + getName());
          urlList[j] = archives[j].toURI().toURL();
        }
//        loader = new URLClassLoader(urlList, Thread.currentThread().getContextClassLoader());
        loader = new URLClassLoader(urlList);
        Messages.log("loading above JARs with loader " + loader);
//        System.out.println("listing classes for loader " + loader);
//        listClasses(loader);
      }
    }

    // If no archives were found, just use the regular ClassLoader
    if (loader == null) {
      loader = Thread.currentThread().getContextClassLoader();
    }
    return className;
  }


  /*
  // doesn't work with URLClassLoader, but works with the system CL
  static void listClasses(ClassLoader loader) {
//    loader = Thread.currentThread().getContextClassLoader();
    try {
      Field f = ClassLoader.class.getDeclaredField("classes");
      f.setAccessible(true);
      Vector<Class> classes =  (Vector<Class>) f.get(loader);
      for (Class c : classes) {
        System.out.println(c.getName());
      }
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
  */


//  static protected boolean isCandidate(File potential, final ContributionType type) {
//    return (potential.isDirectory() &&
//      new File(potential, type.getFolderName()).exists());
//  }
//
//
//  /**
//   * Return a list of directories that have the necessary subfolder for this
//   * contribution type. For instance, a list of folders that have a 'mode'
//   * subfolder if this is a ModeContribution.
//   */
//  static protected File[] listCandidates(File folder, final ContributionType type) {
//    return folder.listFiles(new FileFilter() {
//      public boolean accept(File potential) {
//        return isCandidate(potential, type);
//      }
//    });
//  }
//
//
//  /**
//   * Return the first directory that has the necessary subfolder for this
//   * contribution type. For instance, the first folder that has a 'mode'
//   * subfolder if this is a ModeContribution.
//   */
//  static protected File findCandidate(File folder, final ContributionType type) {
//    File[] folders = listCandidates(folder, type);
//
//    if (folders.length == 0) {
//      return null;
//
//    } else if (folders.length > 1) {
//      Base.log("More than one " + type.toString() + " found inside " + folder.getAbsolutePath());
//    }
//    return folders[0];
//  }


  LocalContribution copyAndLoad(Base base,
                                boolean confirmReplace,
                                StatusPanel status) {
    // NOTE: null status => function is called on startup
    // when Editor objects, et al. aren't ready

    String contribFolderName = getFolder().getName();

    File contribTypeFolder = getType().getSketchbookFolder();
    File contribFolder = new File(contribTypeFolder, contribFolderName);

    if (status != null) { // when status != null, install is not occurring on startup

      Editor editor = base.getActiveEditor();

      ArrayList<LocalContribution> oldContribs =
        getType().listContributions(editor);

      // In case an update marker exists, and the user wants to install, delete the update marker
      if (contribFolder.exists() && !contribFolder.isDirectory()) {
        contribFolder.delete();
        contribFolder = new File(contribTypeFolder, contribFolderName);
      }

      for (LocalContribution oldContrib : oldContribs) {
        if ((oldContrib.getFolder().exists() && oldContrib.getFolder().equals(contribFolder)) ||
            (oldContrib.getId() != null && oldContrib.getId().equals(getId()))) {

          if (oldContrib.getType().requiresRestart()) {
            if (!oldContrib.backup(false, status)) {
              return null;
            }
            /*
            try {
              Platform.deleteFile(oldContrib.getFolder());
            } catch (IOException e) {
              status.setErrorMessage(e.getMessage());
              return null;
            }
             */
          } else {
            int result = 0;
            boolean doBackup = Preferences.getBoolean("contribution.backup.on_install");
            if (confirmReplace) {
              if (doBackup) {
                result = Messages.showYesNoQuestion(editor, "Replace",
                       "Replace pre-existing \"" + oldContrib.getName() + "\" library?",
                       "A pre-existing copy of the \"" + oldContrib.getName() + "\" library<br>"+
                       "has been found in your sketchbook. Clicking “Yes”<br>"+
                       "will move the existing library to a backup folder<br>" +
                       "in <i>libraries/old</i> before replacing it.");
                if (result != JOptionPane.YES_OPTION || !oldContrib.backup(true, status)) {
                  return null;
                }
              } else {
                result = Messages.showYesNoQuestion(editor, "Replace",
                       "Replace pre-existing \"" + oldContrib.getName() + "\" library?",
                       "A pre-existing copy of the \"" + oldContrib.getName() + "\" library<br>"+
                       "has been found in your sketchbook. Clicking “Yes”<br>"+
                       "will permanently delete this library and all of its contents<br>"+
                       "before replacing it.");
                if (result != JOptionPane.YES_OPTION || !oldContrib.getFolder().delete()) {
                  return null;
                }
              }
            } else {
              if ((doBackup && !oldContrib.backup(true, status)) ||
                  (!doBackup && !oldContrib.getFolder().delete())) {
                return null;
              }
            }
          }
        }
      }

      // At this point it should be safe to replace this fella
      if (contribFolder.exists()) {
        Util.removeDir(contribFolder);
      }

    } else {
      // This if should ideally never happen, since this function
      // is to be called only when restarting on update
      if (contribFolder.exists() && contribFolder.isDirectory()) {
        Util.removeDir(contribFolder);
      }
      else if (contribFolder.exists()) {
        contribFolder.delete();
        contribFolder = new File(contribTypeFolder, contribFolderName);
      }
    }

    File oldFolder = getFolder();

    try {
      Util.copyDir(oldFolder, contribFolder);
    } catch (IOException e) {
      status.setErrorMessage("Could not copy " + getTypeName() +
                             " \"" + getName() + "\" to the sketchbook.");
      e.printStackTrace();
      return null;
    }


    /*
    if (!getFolder().renameTo(contribFolder)) {
      status.setErrorMessage("Could not move " + getTypeName() +
                                " \"" + getName() + "\" to the sketchbook.");
      return null;
    }
    */

    return getType().load(base, contribFolder);
  }


  /**
   * Moves the given contribution to a backup folder.
   * @param deleteOriginal
   *          true if the file should be moved to the directory, false if it
   *          should instead be copied, leaving the original in place
   */
  boolean backup(boolean deleteOriginal, StatusPanel status) {
    File backupFolder = getType().createBackupFolder(status);

    boolean success = false;
    if (backupFolder != null) {
      String libFolderName = getFolder().getName();
      String prefix = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
      final String backupName = prefix + " " + libFolderName;
      File backupSubFolder =
        ContributionManager.getUniqueName(backupFolder, backupName);

      if (deleteOriginal) {
        success = getFolder().renameTo(backupSubFolder);
      } else {
        try {
          Util.copyDir(getFolder(), backupSubFolder);
          success = true;
        } catch (IOException e) { }
      }
      if (!success) {
        status.setErrorMessage("Could not move contribution to backup folder.");
      }
    }
    return success;
  }


  /**
   * Non-blocking call to remove a contribution in a new thread.
   */
  void removeContribution(final Base base,
                          final ContribProgressMonitor pm,
                          final StatusPanel status) {
    // TODO: replace with SwingWorker [jv]
    new Thread(new Runnable() {
      public void run() {
        remove(base,
               pm,
               status,
               ContributionListing.getInstance());
      }
    }, "Contribution Uninstaller").start();
  }


  void remove(final Base base,
              final ContribProgressMonitor pm,
              final StatusPanel status,
              final ContributionListing contribListing) {
    pm.startTask("Removing", ContribProgressMonitor.UNKNOWN);

    boolean doBackup = Preferences.getBoolean("contribution.backup.on_remove");
//    if (getType().requiresRestart()) {
//      if (!doBackup || (doBackup && backup(editor, false, status))) {
//        if (setDeletionFlag(true)) {
//          contribListing.replaceContribution(this, this);
//        }
//      }
//    } else {
    boolean success = false;
    if (getType() == ContributionType.MODE) {
      boolean isModeActive = false;
      ModeContribution m = (ModeContribution) this;
      Iterator<Editor> iter = base.getEditors().iterator();
      while (iter.hasNext()) {
        Editor e = iter.next();
        if (e.getMode().equals(m.getMode())) {
          isModeActive = true;
          break;
        }
      }
      if (!isModeActive) {
        m.clearClassLoader(base);
      } else {
        pm.cancel();
        Messages.showMessage("Mode Manager",
                             "Please save your Sketch and change the Mode of all Editor\n" +
                             "windows that have " + name + " as the active Mode.");
        return;
      }
    }

    if (getType() == ContributionType.TOOL) {
      /*
      ToolContribution t = (ToolContribution) this;
      Iterator<Editor> iter = editor.getBase().getEditors().iterator();
      while (iter.hasNext()) {
        Editor ed = iter.next();
        ed.clearToolMenu();
      }
      t.clearClassLoader(editor.getBase());
      */
      // menu will be rebuilt below with the refreshContribs() call
      base.clearToolMenus();
      ((ToolContribution) this).clearClassLoader();
    }

    if (doBackup) {
      success = backup(true, status);
    } else {
      success = Util.removeDir(getFolder(), false);
    }

    if (success) {
      // this was just rebuilding the tool menu in one editor, which happens
      // yet again down below with the call to refreshInstalled() [fry 150828]
//      if (getType() == ContributionType.TOOL) {
//        editor.removeTool();
//      }

      try {
        // TODO: run this in SwingWorker done() [jv]
        EventQueue.invokeAndWait(new Runnable() {
          @Override
          public void run() {
            Contribution advertisedVersion =
                contribListing.getAvailableContribution(LocalContribution.this);

            if (advertisedVersion == null) {
              contribListing.removeContribution(LocalContribution.this);
            } else {
              contribListing.replaceContribution(LocalContribution.this, advertisedVersion);
            }
            base.refreshContribs(LocalContribution.this.getType());
            base.setUpdatesAvailable(contribListing.countUpdates(base));
          }
        });
      } catch (InterruptedException e) {
        e.printStackTrace();
      } catch (InvocationTargetException e) {
        Throwable cause = e.getCause();
        if (cause instanceof RuntimeException) {
          throw (RuntimeException) cause;
        } else {
          cause.printStackTrace();
        }
      }

    } else {
      // There was a failure backing up the folder
      if (!doBackup || (doBackup && backup(false, status))) {
        if (setDeletionFlag(true)) {
          try {
            // TODO: run this in SwingWorker done() [jv]
            EventQueue.invokeAndWait(new Runnable() {
              @Override
              public void run() {
                contribListing.replaceContribution(LocalContribution.this,
                                                   LocalContribution.this);
                base.refreshContribs(LocalContribution.this.getType());
                base.setUpdatesAvailable(contribListing.countUpdates(base));
              }
            });
          } catch (InterruptedException e) {
            e.printStackTrace();
          } catch (InvocationTargetException e) {
            Throwable cause = e.getCause();
            if (cause instanceof RuntimeException) {
              throw (RuntimeException) cause;
            } else {
              cause.printStackTrace();
            }
          }
        }
      } else {
        status.setErrorMessage("Could not delete the contribution's files");
      }
    }
    if (success) {
      pm.finished();
    } else {
      pm.cancel();
    }
  }


  public File getFolder() {
    return folder;
  }


  public boolean isInstalled() {
    return folder != null;
  }


//  public String getCategory() {
//    return category;
//  }
//
//
//  public String getName() {
//    return name;
//  }


  public String getId() {
    return id;
  }


//  public String getAuthorList() {
//    return authorList;
//  }
//
//
//  public String getUrl() {
//    return url;
//  }
//
//
//  public String getSentence() {
//    return sentence;
//  }
//
//
//  public String getParagraph() {
//    return paragraph;
//  }
//
//
//  public int getVersion() {
//    return version;
//  }


  public int getLatestVersion() {
    return latestVersion;
  }


//  public String getPrettyVersion() {
//    return prettyVersion;
//  }
//
//
//  public String getTypeName() {
//    return getType().toString();
//  }


  /*
  static protected String findClassInZipFileList(String base, File[] fileList) {
    for (File file : fileList) {
      String found = findClassInZipFile(base, file);
      if (found != null) {
        return found;
      }
    }
    return null;
  }
  */


  /**
   * Returns the imports (package-names) for a library, as specified in its library.properties
   * (e.g., imports=libname.*,libname.support.*)
   *
   * @return String[] packageNames (without wildcards) or null if none are specified
   */
  public StringList getImports() {
    //return imports != null ? imports.toArray(new String[0]) : null;
    return imports;
  }


  // this duplicates code found in Contribution (though that version doesn't check for .* at the end)
//  /**
//   * @return the list of Java imports to be added to the sketch when the library is imported
//   * or null if none are specified
//   */
//  static StringList parseImports(String importsStr) {
//    StringList outgoing = new StringList();
//
//    if (importsStr != null) {
//      String[] listing = PApplet.trim(PApplet.split(importsStr, ','));
//      for (String imp : listing) {
//
//        // In case the wildcard is specified, strip it, as it gets added later)
//        if (imp.endsWith(".*")) {
//
//          imp = imp.substring(0, imp.length() - 2);
//        }
//
//        outgoing.add(imp);
//      }
//    }
////    return (outgoing.size() > 0) ? outgoing : null;
//    return outgoing;
//  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  boolean setDeletionFlag(boolean flag) {
    return setFlag(DELETION_FLAG, flag);
  }


  boolean isDeletionFlagged() {
    return isDeletionFlagged(getFolder());
  }


  static boolean isDeletionFlagged(File folder) {
    return isFlagged(folder, DELETION_FLAG);
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  boolean setUpdateFlag(boolean flag) {
    return setFlag(UPDATE_FLAGGED, flag);
  }


  boolean isUpdateFlagged() {
    return isUpdateFlagged(getFolder());
  }


  static boolean isUpdateFlagged(File folder) {
    return isFlagged(folder, UPDATE_FLAGGED);
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  boolean setRestartFlag() {
    //System.out.println("setting restart flag for " + folder);
    return setFlag(RESTART_FLAG, true);
  }


  @Override
  boolean isRestartFlagged() {
    //System.out.println("checking for restart inside LocalContribution for " + getName());
    return isFlagged(getFolder(), RESTART_FLAG);
  }


  static void clearRestartFlags(File folder) {
    File restartFlag = new File(folder, RESTART_FLAG);
    if (restartFlag.exists()) {
      restartFlag.delete();
    }
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  private boolean setFlag(String flagFilename, boolean flag) {
    if (flag) {
      // Only returns false if the file already exists, so we can
      // ignore the return value.
      try {
        new File(getFolder(), flagFilename).createNewFile();
        return true;
      } catch (IOException e) {
        return false;
      }
    } else {
      return new File(getFolder(), flagFilename).delete();
    }
  }


  static private boolean isFlagged(File folder, String flagFilename) {
    return new File(folder, flagFilename).exists();
  }


  // . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .


  /**
   *
   * @param base name of the class, with or without the package
   * @param file
   * @return name of class (with full package name) or null if not found
   */
  static protected String findClassInZipFile(String base, File file) {
    // Class file to search for
    String classFileName = "/" + base + ".class";

    try {
      ZipFile zipFile = new ZipFile(file);
      Enumeration<?> entries = zipFile.entries();
      while (entries.hasMoreElements()) {
        ZipEntry entry = (ZipEntry) entries.nextElement();

        if (!entry.isDirectory()) {
          String name = entry.getName();
//          System.out.println("entry: " + name);

          if (name.endsWith(classFileName)) {
            //int slash = name.lastIndexOf('/');
            //String packageName = (slash == -1) ? "" : name.substring(0, slash);
            // Remove .class and convert slashes to periods.
            zipFile.close();
            return name.substring(0, name.length() - 6).replace('/', '.');
          }
        }
      }
      zipFile.close();
    } catch (IOException e) {
      //System.err.println("Ignoring " + filename + " (" + e.getMessage() + ")");
      e.printStackTrace();
    }
    return null;
  }
}
