
import java.lang.reflect.*;
import java.net.*;
import java.io.*;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.HashSet;
import java.util.regex.Pattern;

/** This class will "jump start" the NeXtMidas shell. This is necessary to enable run-time
    modification of the classpath and aliasing of NeXtMidas option tree paths. <br>
    <br>
    Class loading in Java is not a "black art," but it's close. Here are a few of the key points
    that will help you understand the reason this class is as it is:
    <ul>
      <li>Class loaders always "delegate upward," if a parent class loader can load the class,
          it will (and won't even tell the child class loader about it). This is backwards from
          almost all other Java functionality (there is a very good reason for this, but it is
          not fun to explain).</li>
      <li>If a class can be found by the class path specified when starting the Java VM (using
          <code>java -classpath [classpath]</code>) it will be loaded by the default class loader
          (in truth this is is an over-simplification of the issue since there are multiple levels
          of class loaders internal to the Java VM).</li>
      <li>By default a class will use whatever class loader loaded it to load its dependencies.
          (If class loader A loads the Shell, every class used by Shell and every class that those
          classes use, and so forth, will use class loader A by default.) Thus the only way to
          use a custom class loader B is to make sure that Shell is loaded with that class loader.
          </li>
      <li>The default class loader supplied by the Java VM does not support any of the "cool
          features" (classpath altering, aliasing, etc.) that NeXtMidas requires.</li>
    </ul>
    So to make things work we must use this class to "boot" NeXtMidas. This class does the dirty
    work of inserting in a custom class loader (which an instance of this class) and then forcing
    the Shell to be loaded using the custom class loader and finally starting up the Shell using
    the standard <code>public static void main(String[] args)</code> method in the Shell. <br>
    <br>
    This class is generic enough that almost any shell can be used, just pass Java the
    <code>-DSHELL=[myshell]</code> argument to specify the shell. <br>
    <br>
    The following definitions alter the running of this class:
    <pre>
      -DNMROOT=           - (REQUIRED) Sets NMROOT.
      -DJAVAHOME=         - (REQUIRED) Sets JAVAHOME.
      -DCLASSPATH=        - (OPTIONAL) Sets an extra classpath elements to initially APPEND to the
                            custom class loader after the NeXtMidas PATH on JVM startup. By default
                            "$NMROOT/" and all the SYS JAR files in "$NMROOT/nxm/sys/jars/" will
                            always be added to the classpath automatically. Since NeXtMidas 2.9.3
                            this APPENDS to classpath (after $NMROOT, SYS's jars, and option trees
                            on path), use -DCLASSPATAFTERSYS for previous behavior).
      -DCLASSPATHAFTERSYS=- (OPTIONAL) Sets extra classpath elements right after $NMROOT and SYS's
                            jars for the custom class loader. This is used for the old behavior
                            prior to NeXtMidas 2.9.3.
      -DCLASSPATHPREPEND= - (OPTIONAL) Sets extra classpath elements to PREPEND to classpath for
                            the custom class loader. Since NeXtMidas 2.9.3.
      -DSHELL=            - (OPTIONAL) Sets the shell to run. This must be the fully-qualified
                            class name of the shell (default="nxm.sys.lib.Shell").
    </pre>
    <table border="1">
      <caption style="visibility:hidden">Note that this class should be self contained</caption>
      <tr><td style="color:red">
        It is absolutely essential that this class not reference ANY element of NeXtMidas
        outside of this class other than in the {@link #SHELL} variable. <br>
        <br>
        Failure to observe this rule will break the intended functionality of this class.
      </td></tr>
    </table>

    @author  J. Czechowski IV / Neon Ngo
    @since   NeXtMidas 2.1.0
 */
public final class NmClassLoader extends URLClassLoader {
  /** The name of the default main class (Shell) to run after initialization. */
  public  static final String    SHELL = "nxm.sys.lib.Shell";
  /** Turns on DEBUG mode.           */
  private static final boolean   DEBUG      = false;
  /** Turns on classLoader debugging.*/
  private static       boolean   clDebug    = DEBUG;
  /** Filters for Jar/Zip files.     */
  private static final JarFilter JAR_FILTER = new JarFilter();
  /** comma (',') or OS path-separator (Unix=':' or DOS/Window=';') regex (compiled) */
  private static final Pattern SEPARATOR_REGEX = Pattern.compile("[,"+File.pathSeparator+"]");

  /** Additional required classpath elements. These should use the path separator <code>'/'</code> and any
      that start with <code>$JAVAHOME</code> or <code>$NMROOT</code> will be relative to those
      respective locations. <br>
      <br>
      If it is pre java 9, the current elements are:
      <pre>
        $JAVAHOME/lib/tools.jar
      </pre>
      Otherwise, there are no elements.<br>
      <br>
      Note: <code>$NMROOT</code> and all the jars in <code>$NMROOT/nxm/sys/jars/</code> will be
      automatically included and should not be listed here.
   */
  static final String[] ADDITIONAL_CLASSPATH = { getConditionalPath()};

  /** set of all URLs that have been added to classpath for this ClassLoader,
      so that we don't have to loop over items returned from getURLs() to check
      if item is already in current classpath. */
  private HashSet<URL> urlSet;

  /** Creates a new instance with no URL's.
      @param parent The parent class loader.
   */
  private NmClassLoader (ClassLoader parent) {
    super(new URL[0], parent);
    urlSet = new HashSet<URL>();
  }

  /**
   * Adds the tools jar to the classpath if it exists.
   * If it is java 9 or later, the tools.jar does not exist so it is not added.
   * @return The path of the tools.jar if it is available
   */
  static private String getConditionalPath() {
    String conditionalPath = "";
    String javaHome = System.getenv("JAVAHOME");
    String preJava9ToolsJar = javaHome+"/lib/tools.jar";
    File f = new File(preJava9ToolsJar);
    if(f.exists() && !f.isDirectory()) { 
      conditionalPath = preJava9ToolsJar;
    }
    return conditionalPath;
  }

  @Override
  public String toString() {
    ClassLoader parent = getParent();
    if (parent != null) {
      return "NmClassLoader: hashcode=" + hashCode() + "\n"
           + " URLs: " + Arrays.asList(getURLs()) + "\n"
           + " parent { " + getParent() + " }\n";
   } else {
      return "NmClassLoader: hashcode=" + hashCode() + "\n"
           + " URLs: " + Arrays.asList(getURLs()) + "\n";
   }
  }

  public synchronized void addURL (URL url) {
    if (url == null) { return; } // ignore null url

    /* Check for duplicates... don't add duplicates. */
    boolean addIt = !urlSet.contains(url);
    if (addIt) {
      if (clDebug) { System.out.println("NmClassLoader.addURL(" + url + ")"); }

      /* Add (append) it to classpath. */
      super.addURL(url);
      urlSet.add(url);

      File file = null;
      try {
        file = new File(url.toURI());
      } 
      catch (Exception e) {
        System.out.println("Unable to create file object from URL "+url);
        System.out.println(e);
      }
      
      /* Update java.class.path property. -- Do this to support legacy code. */
      final String prop  = (file == null) ? (url.getProtocol().equals("file")) ? url.getPath(): url.toString() : file.getPath();
      final String oldCP = getProperty("java.class.path","").trim();
      final String newCP = oldCP + File.pathSeparator + prop;

      System.setProperty("java.class.path", newCP);
    }
    else {
      if (clDebug) {
        System.out.println("NmClassLoader.addURL(" + url + ") .. Already added, Skipping");
      }
    }
  }

  /** Adds all the Jar and Zip files in a directory to the classpath.
      @param dir The directory.
      @throws MalformedURLException If a URL can not be made for any of the Jars.
   */
  private void addJarsInDirectory (File dir) {
    if ((dir != null) && dir.exists() && dir.isDirectory()) {
      final File[] jars = dir.listFiles(JAR_FILTER);

      for (File file : jars) {
        try {
          addURL(file.toURI().toURL());
        } catch(MalformedURLException e) {
          System.err.println("ERROR: Can not add JAR '" + file + "' to classpath: " + e);
        }
      } // end for loop
    }
  }

  /** Gets a basic class loader with the default path pre-loaded. */
  private void addDefaultUrls () {
    /* Add initial classpath to the new class loader. */
    final String nmroot    = getProperty("NMROOT",    "");
    final String javahome  = getProperty("JAVAHOME",  "");
    final String prependClasspath = getProperty("CLASSPATHPREPEND", null);

    // 1. Add any special user-defined PREPEND classpath elements. (Since 2.9.3 for BUG 1795)
    if (prependClasspath != null) {
      String[] elements = SEPARATOR_REGEX.split(prependClasspath); // Split the path up
      for (String element : elements) {
        addURL(element);
      }
    }

    // 2. Add NMROOT and the Jars for SYS to classpath
    final File jars = new File(nmroot + "nxm" + File.separator + "sys" + File.separator + "jars");
    addURL(nmroot);
    addJarsInDirectory(jars);

    // 3. Add additional standard classpath elements.
    for (String cp : ADDITIONAL_CLASSPATH) {
      String file = cp.replace('/', File.separatorChar); // Fix for Windows

      if (file.startsWith("$NMROOT"  )) { file = nmroot   + file.substring(7); }
      if (file.startsWith("$JAVAHOME")) { file = javahome + file.substring(9); }

      addURL(file);
    }

    // 4. Add any special user-defined APPEND after SYS classpath elements (Since 2.9.3 for BUG 1795)
    final String appendAfterSys = getProperty("CLASSPATHAFTERSYS", null);
    if (appendAfterSys != null) {
      String[] elements = SEPARATOR_REGEX.split(appendAfterSys); // Split the path up
      for (String element : elements) {
        addURL(element);
      }
    }

  }

  /** Adds a URL to the classpath. If the given URL is really a local file/directory path then
      make it a "file:" URL.
      @param url The url.
   */
  private void addURL (String url) {
    try {
      url = (url != null)? url.trim(): null;

      if ((url == null) || (url.length() == 0)) {
        /* Do Nothing */
      }
      else {
        /* Figure out if the url is a real URL or just a local file/directory. */
        if (url.indexOf(':') > 1) { // Use >1 to allow for drive letter on Windows
          addURL((new URI(url)).toURL());
        }
        else {
          addURL((new File(url)).toURI().toURL());
        }
      }
    } catch (MalformedURLException | URISyntaxException e) {
      System.err.println("ERROR: Invalid element in CLASSPATH (" + url + "): " + e);
    }
  }

  /** Processes the defines from the arguments.
      @param str The arguments.
      @return The arguments without the defines or null if an error/help message was output and
              now able to exit.
   */
  private static String processDefines (String str) {
    final String sep   = File.separator;      // '/' on Unix, '\\' on DOS
    final String pid   = getPid().toString(); // Junk PID to use if not already set.
    final String jhDef = getJavaHomeDef();    // Default JAVAHOME to use.

    /* Parse any runtime parameters. */
    str = str.trim();

    String arg;
    String val;

    while ((str!= null) && str.startsWith("-")) {
      int start = str.indexOf('=')+1; //Start of value
      int end   = str.indexOf(' ');   //End of value
      if (end < 0) { end = str.length(); } // Last in line

      if ((start > 0) && (end >= start)) {
        arg = str.substring(1,start-1).toUpperCase();
        val = str.substring(start,end);
        str = str.substring(end).trim();
      }
      else {
        arg = str.substring(1,end).toUpperCase();
        val = null;  // No value associated (-H, -HELP, -V or -VERSION)
        str = str.substring(end).trim();
      }

      if (arg.startsWith("D") && (arg.length() > 1)) {
        System.setProperty(arg.substring(1), val);
      }
      else if (arg.equals("H") || arg.equals("HELP")) {
        str = null;
      }
      else if (arg.equals("LOG_OUT") || arg.equals("LOG_ERR") || arg.equals("LOG_ALL")) {
        boolean append = true;

        try {
          FileOutputStream fileOut = new FileOutputStream(val, append);

          if (arg.equals("LOG_OUT") || arg.equals("LOG_ALL")) {
            System.setOut(new PrintStream(new CopyOutputStream(System.out, fileOut)));
          }

          if (arg.equals("LOG_ERR") || arg.equals("LOG_ALL")) {
            System.setErr(new PrintStream(new CopyOutputStream(System.err, fileOut)));
          }

        } catch (IOException e) {
          System.err.println("ERROR: Unable to set "+arg+" to '"+val+"': " + e);
          str = null;
        }

      } else if (arg.equals("CLASSLOADERDEBUG")) {
        if ((val!=null) && val.equalsIgnoreCase("TRUE")) {
          clDebug = true;
        } else if ((val!=null) && val.equalsIgnoreCase("FALSE")) {
          clDebug = false;
        } else {
          System.err.println("Unknown option '-CLASSLOADERDEBUG="+val+"' expected "
                           + "-CLASSLOADERDEBUG=TRUE or -CLASSLOADERDEBUG=FALSE");
        }

      } else if (arg.equals("I") || arg.equals("INDEPENDENT")) {
        setProperty("JAVAHOME",   jhDef);
        setProperty("NMPID",      pid);
        setProperty("NOEXTERNAL", "TRUE");
        setProperty("NMSF",       "NULL");
        setProperty("SHELL",      SHELL);

        String   classpath = getProperty("java.class.path","").trim();
        String[] elements  = SEPARATOR_REGEX.split(classpath);

        if ((elements.length!=1) || !(classpath.endsWith("tools")||classpath.endsWith("tools"+sep))){
          System.err.println("ERROR: Java classpath not properly set, given '"+classpath+"'.");
          str = null;
        }
        else {
          File nmroot = (new File(classpath)).getParentFile();

          if (nmroot.exists()) {
            setProperty("NMROOT", nmroot.getAbsolutePath());
          }
          else {
            System.err.println("ERROR: Can not set NMROOT='"+nmroot.getAbsolutePath()+"'.");
            str = null;
          }
        }
      }
      else if (arg.equals("V") || arg.equals("VERSION")) {
        str = "VERSION/ALL SYS";
      }
      else {
        System.err.println("ERROR: Unknown option "+arg+"="+val);
        str = null;
      }
    }

    String nmpid    = getProperty("NMPID",    pid,   true);
    String nmroot   = getProperty("NMROOT",   null,  true);
    String javaHome = getProperty("JAVAHOME", jhDef, false);

    /* Enforce the "end with path separator" rules, also verify that they exist. */
    File nmr = (nmroot==null)? null: new File(nmroot).getAbsoluteFile();
    File jh  = new File(javaHome).getAbsoluteFile();

    if ((nmroot==null) || nmroot.trim().equals("") || !nmr.exists() || !nmr.isDirectory()) {
      System.err.println("ERROR: Unable to access directory NMROOT='"+nmr+"'.");
      str = null;
    }
    if (javaHome.trim().equals("") || !jh.exists() || !jh.isDirectory()) {
      System.err.println("ERROR: Unable to access directory JAVAHOME='"+jh+"'.");
      str = null;
    }

    setProperty("NMROOT",   nmr.getPath() + sep); // Forces it to end with a '/'
    setProperty("JAVAHOME", jh.getPath()  + sep); // Forces it to end with a '/'
    setProperty("javahome", jh.getPath()       ); // Forces it to end without a '/'

    if (DEBUG) { // <-- Set true for debugging info.
      System.out.println("NMROOT="+getProperty("NMROOT", null));
      System.out.println("NMPID="+getProperty("NMPID", null));
      System.out.println("NMSF="+getProperty("NMSF", null));
      System.out.println("JAVAHOME="+getProperty("JAVAHOME", null));
      System.out.println("javahome="+getProperty("javahome", null));
    }

    /* Show help if applicable. */
    if (str == null) { showHelp(); }

    return str;
  }

  /** Generate a fake PID to use.
      @return The PID.
   */
  private static Integer getPid () {
    try {
      SecureRandom random = SecureRandom.getInstance("SHA1PRNG", "SUN");
      random.setSeed(System.currentTimeMillis());

      int rand = random.nextInt();

      if (rand<0){ rand=(1-rand); }  // Note use 1-rand to get abs value -- reflective around 0

      return Integer.valueOf(rand);

    } catch(Exception e) {
      return Integer.valueOf((int)System.currentTimeMillis());
    }
  }

  /** Shows help. This is used ONLY by setDefines(String[]). */
  private static void showHelp () {
    try {
      String nmroot = getProperty("NMROOT",null,true);
      String fname  = "" + nmroot + File.separator + "nxm" + File.separator + "sys"
                    + File.separator + "docs" + File.separator + "RuntimeOptions.txt";

      catFile(System.out, fname);

    } catch(Exception e) {
      System.err.println("ERROR: Unable to display help for nm: " + e.getMessage());
      //e.printStackTrace();
    }
  }

  /** Gets a system property.
      @param name The name of the property.
      @param def  The default to return if property is not set or not able to be accessed.
      @return The value of the property.
   */
  private static String getProperty (String name, String def) {
    return getProperty(name, def, false);
  }

  /** Gets a system property. If the property is not set, it will set it to <code>def</code> if
      <code>def!=null</code>
      @param name The name of the property.
      @param def  The default to return if property is not set or not able to be accessed.
      @param warn Print a warning if not found.
      @return The value of the property.
   */
  private static String getProperty (String name, String def, boolean warn) {
    try {
      String prop = System.getProperty(name, null); // May throw SecurityException

      if (prop==null) {
        prop = def;
        if (warn) { System.err.println("WARN: "+name+" not set, using default ("+def+")."); }
        if (prop!=null) { setProperty(name, prop); }
      }

      return prop;

    } catch (Exception e) {
      System.err.println("ERROR: Unable to access "+name+": "+e.getMessage());
      return def;
    }
  }

  /** Sets a system property.
      @param name  The name of the property.
      @param value The new value.
   */
  private static void setProperty (String name, String value) {
    try {
      System.setProperty(name, value); // May throw SecurityException
    } catch(Exception e) {
      /* Do Nothing */
    }
  }

  /** Gets a default JAVAHOME to use based on the "java.home" property.
      @return The default JAVAHOME to use.
   */
  private static String getJavaHomeDef () {
    File javaHomeDef = new File(getProperty("java.home", null)); // <--java.home is NEVER null
    javaHomeDef = javaHomeDef.getAbsoluteFile();

    /* If the JRE is given and it is in a JDK, use the JDK */
    if (javaHomeDef.getName().equalsIgnoreCase("jre")) {
      File bin = new File(javaHomeDef.getParentFile(), "bin"); // JDK bin
      if (bin.exists() && bin.isDirectory()) {
        File java    = new File(bin, "java");     // Everyone but DOS
        File javaExe = new File(bin, "java.exe"); // DOS

        if (java.exists() || javaExe.exists()) {
          javaHomeDef = javaHomeDef.getParentFile();
        }
      }
    }

    return javaHomeDef.getAbsolutePath();
  }

  /** Cats a file to a given output stream.

      @param out   The output stream (usually System.out).
      @param fname The name of the file.

      @throws IOException If an error occurs while reading or writing.
   */
  private static void catFile (PrintStream out, String fname) throws IOException {
    BufferedReader in = new BufferedReader(new FileReader(fname));
    String line;

    while ((line = in.readLine()) != null) {
      out.println(line);
    }

    in.close();
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  // MAIN METHOD
  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** This will launch the shell class using a new {@link NmClassLoader} as the class loader.
      @param args Any command-line arguments (passed directly to the shell).
      @throws Throwable If an exception or error occur.
   */
  public static void main (String[] args) throws Throwable {
    /* Run NeXtMidas. */
    Object[] opts = loadShell(args);

    if (opts != null) {
      Class<?>  mainClass  = (Class)opts[0];
      Method mainMethod = mainClass.getMethod("main", new Class<?>[] { String[].class } );

      mainMethod.invoke(null, new Object[] { opts[1] });
    }
  }

  /** This will load a shell using a new {@link NmClassLoader} as the class loader. <br>
      <br>
      <b>This method is for use via JNI by methods in <code>nxm/sys/libxm/jni.c</code>.
      Do not use!</b>

      @param args Any command-line arguments (passed directly to the shell).

      @return A new instance of the shell class constructed using the no-argument constructor or
              <code>null</code> if command-line arguments prevent the instantiation of the shell
              (usually due to using the "-HELP" option).

      @throws Throwable If an exception or error occur.
   */
  public static Object initShell (String[] args) throws Throwable {
    Class<?> clazz = (Class)loadShell(args)[0];
    Object shell = null;

    if (clazz != null) {
      Constructor<?> constructor = clazz.getConstructor(new Class<?>[0]);

      shell = constructor.newInstance(new Object[0]);
    }

    return shell;
  }

  /** This will load the shell class using a new {@link NmClassLoader} as the class loader.

      @param args Any command-line arguments (passed directly to the shell).

      @return A shell class or <code>null</code> if command-line arguments prevent the shell from
              starting (usually due to using the "-HELP" option).

      @throws Throwable If an exception or error occur.
   */
  private static Object[] loadShell (String[] args) throws Throwable {
    final ClassLoader   oldCL = Thread.currentThread().getContextClassLoader();
    final NmClassLoader newCL = new NmClassLoader(oldCL);

    /* Pre-process the arguments and pull out the defines. */
    StringBuilder argStr = new StringBuilder(128);

    if (args != null) {
      for (String arg : args) {
        argStr.append(' ');
        argStr.append(arg);
      }
    }

    String newArgs = processDefines(argStr.toString());

    if (newArgs == null) {
      return null; // Error/Help displayed, nothing to do.
    }
    else {
      args = new String[] { newArgs };
    }

    /* Add the default classpath options. */
    newCL.addDefaultUrls();

    /* Set the new class loader as the one to use. */
    Thread.currentThread().setContextClassLoader(newCL);

    /* Determine what class to run as the shell. */
    String className = getProperty("SHELL", null);

    if (className == null) {
      className = SHELL;
      System.err.println("WARNING: NeXtMidas started without SHELL class being specified using "
                       + "'" + SHELL + "' use -DSHELL=<class name> to set the shell.");
    }

    /* Run NeXtMidas. */
    final Class<?> mainClass  = Class.forName(className, true, newCL);
//    final Method mainMethod = mainClass.getMethod("main", new Class[] { args.getClass() } );
//
//    mainMethod.invoke(null, new Object[] { args });

    return new Object[] { mainClass, args };
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  // NESTED CLASSES
  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Filter that accepts file names that match a Jar (*.jar) or Zip (*.zip) archive. */
  private static final class JarFilter implements FilenameFilter {
    public boolean accept(java.io.File dir, String name) {
      name = name.toLowerCase();

      return name.endsWith(".jar") || name.endsWith(".zip");
    }
  } // end class JarFilter

  /** An output stream that writes all data to two separate output streams. */
  private static final class CopyOutputStream extends OutputStream {
    /** The first stream to write to.  */ private final OutputStream first;
    /** The second stream to write to. */ private final OutputStream second;

    /** Creates a new instance.
        @param first  The first output stream.
        @param second The second output stream.
     */
    public CopyOutputStream (OutputStream first, OutputStream second) {
      this.first  = first;
      this.second = second;
    }

    public void write (int b) throws IOException {
      first.write(b);
      second.write(b);
      flush();
    }

    public void flush () throws IOException {
      first.flush();
      second.flush();
    }

    public void close () throws IOException {
      first.close();
      second.close();
    }
  } // end class CopyOutputStream

} // end class NmClassLoader
