package nxm.sys.lib;

import java.io.BufferedOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarInputStream;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.zip.ZipEntry;

import nxm.sys.inc.InternalUseOnly;
import nxm.sys.inc.MidasReference;
import nxm.sys.inc.ProvisionalUseOnly;

/**
  A class to handle JAR files.

  @author Jay Hallam
*/
public class JarFile extends ZipFile {
  Manifest manifest; // The JAR file manifest

  /** JarFile Default Constructor */
  public JarFile () { }


  /** @param ref      reference
   *  @param filename file name
   *  @deprecated since NeXtMidas 3.5.0 use {@link #JarFile(MidasReference, Object)} */
  @Deprecated
  public JarFile (Object ref, Object filename) {
    init(Convert.ref2Midas(ref), filename);
  }

  /** JarFile Constructor
   *  @param ref      Midas reference
   *  @param filename file name
   *  @since NeXtMidas 3.5.0 */
  public JarFile (MidasReference ref, Object filename) {
    init(ref, filename);
  }

  public String getMimeType () {
    return "application/x-java-archive";
  }

  ZipEntry[] readZipFile () throws IOException {
    String fname = FileUtil.toLocalFileName(getURL());
    java.util.jar.JarFile jarFile = new java.util.jar.JarFile(fname);
    manifest = jarFile.getManifest();
    return (fname == null)? null : readZipFile(jarFile);
  }

  ZipEntry[] readZipInputStream () throws IOException {
    JarInputStream in = new JarInputStream(io.getInputStream());
    manifest = in.getManifest();
    return readZipInputStream(in);
  }

  public String listHeader() {
    return "JarFile          : "+getURL()+"\n"
         + "Entries          : "+getNumEntries()+"\n"
         + "Bytes            : "+getSize()+"\n"
         + "Main Attributes  : "+listMainAttributes()
         + "Manifest Entries : "+listManifestEntries();
  }

  /** Used only by listHeader() */
  private String listMainAttributes () {
    StringBuilder attr = new StringBuilder();
    if (manifest == null) {
      attr.append("(not available)\n");
    } else {
      attr.append("\n");

      Iterator<Entry<Object, Object>> iter = manifest.getMainAttributes().entrySet().iterator();
      while (iter.hasNext()) {
        Map.Entry<Object,Object> entry = iter.next();
        String    key   = entry.getKey().toString();
        String    value = entry.getValue().toString();

        attr.append("  ").append(StringUtil.padRight(key,32));
        attr.append(" : ").append(value).append('\n');
      }
    }
    return attr.toString();
  }

  /** Used only by listHeader() */
  private String listManifestEntries () {
    StringBuilder ent = new StringBuilder();

    if (manifest == null) {
      ent.append("(not available)\n");
    } else {
      ent.append("(excludes SHA1-Digest)\n");

      Iterator<String> entries = manifest.getEntries().keySet().iterator();
      while (entries.hasNext()) {
        String   name    = entries.next().toString();
        boolean  anyAttr = false;
        Iterator<Entry<Object, Object>> attIter = manifest.getAttributes(name).entrySet().iterator();
        while (attIter.hasNext()) {
          Map.Entry<Object,Object> entry = attIter.next();
          String    key   = entry.getKey().toString();
          String    value = entry.getValue().toString();

          if (!key.equals("SHA1-Digest")) {
            if (!anyAttr) {
              anyAttr = true;
              ent.append("  ").append(StringUtil.padRight(name,32)).append(" :");
            }
            ent.append(' ').append(key).append('=').append(value);
          }
        }
        if (anyAttr) ent.append('\n');
      }
    }
    return ent.toString();
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  // Methods to implement ListFile
  //////////////////////////////////////////////////////////////////////////////////////////////////
  void notEditable () { throw new UnsupportedOperationException("JarFile is not editable."); }

  public Table getDataTable (double offset) {
    if (offset < 0) return null;
    if (offset >= zipEntries.length) return null; // end-of-file

    Table    t = super.getDataTable(offset);
    JarEntry e = (JarEntry)zipEntries[(int)offset];
    StringBuilder attrs = new StringBuilder();
    String   certs = "";
    String signers = "";

    try {
      Attributes a = e.getAttributes();
      Object[]   c = e.getCertificates();
      Object[]   s = e.getCodeSigners();  // since 3.7.0 (using Java 5 API)

      if (a != null) {
        Iterator<Entry<Object, Object>> iter = a.entrySet().iterator();
        while (iter.hasNext()) {
          Map.Entry<Object,Object> ent = iter.next();
          if (attrs.length() != 0) attrs.append(", ");
          attrs.append(ent.getKey()).append(":").append(ent.getValue());
        }
      }
      if (c != null) certs   = StringUtil.join(c);
      if (s != null) signers = StringUtil.join(s);
    } catch (IOException ex) {
      // ignore it (no manifest available)
    }
    t.put("ATTRIBUTES",   attrs);
    t.put("CERTIFICATES", certs);
    t.put("CODE_SIGNERS", signers);       // since 3.7.0

    return t;
  }

  public int getRecordDefCount () {
    return super.getRecordDefCount() + 3; // since 3.7.0
//  return super.getRecordDefCount() + 2; // prior to 3.7.0
  }

  public Table getRecordDef (int i) {
    Table t;
    switch (i - super.getRecordDefCount()) {
      case 0:  t = new Table();  t.put("NAME", "ATTRIBUTES");   break;
      case 1:  t = new Table();  t.put("NAME", "CERTIFICATES"); break;
      case 2:  t = new Table();  t.put("NAME", "CODE_SIGNERS"); break; // Since Java 5
      default: t = super.getRecordDef(i);                       break;
    }
    return t;
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  // Methods to create a JarFile
  //////////////////////////////////////////////////////////////////////////////////////////////////

  /** Signs a JAR file. Note that the JAR file must be on the local disk. This method is usually
    called by the <code>GENERATE SIGN</code> command, but can be called directly if needed.
    It uses the default keystore in SYS.
    Example:
    <pre>
      FileName fname = new FileName(Shell.getOptPath("jars","myopt")+"myjar.jar");
      signJarFile(midas, fname);
    </pre>
    @param midas The current midas context.
    @param fname The name of the JAR file.
  */
  public static void signJarFile (Midas midas, FileName fname) { // TODO: deprecate?
    signJarFile(midas, fname, (String) null, (Table) null, null, false);
  }

  /** Signs a JAR file. Note that the JAR file must be on the local disk. This method is usually
      called by the <code>GENERATE SIGN</code> command, but can be called directly if needed.
      Example:
      <pre>
        String opt = "myopt";
        FileName fname = new FileName(Shell.getOptPath("jars", opt) + "myjar.jar");
        Table keystoreCfg = new Table("{KEYSTOREFILE=/path/to/myKeystore, KEYSTOREPASS=myStorePass, KEYALIAS=myKeyAlias, KEYPASS=myKeyPass}");
        signJarFile(midas, fname, opt, keystoreCfg, "-tsa http://timestamp.authority.hostname", true);
      </pre>
      @param midas The current midas context (null to use global shared midas context).
      @param fname The filename of the JAR file.
      @param opt  (optional) option tree name (null to use SYS's build.props). Since 3.5.2.
      @param customKeystoreCfg (optional) custom keystore configuration table (keys are:
               KEYSTOREFILE=, KEYSTOREPASS=, KEYALIAS=, KEYPASS=, MOREOPTIONS=). null to use default in SYS.
      @param customJarsignerOptions (optional) options to append to jarsigner command line before jar-fname and keyAlias.
      @param debug true to print jarsigner command line that will be executed, false for none.
      @since NeXtMidas 3.5.1 (extended original signJarFile(Midas, FileName) with additional params
   */
  @InternalUseOnly("Since initial version")
  public static void signJarFile (Midas midas, FileName fname, String opt, Table customKeystoreCfg, String customJarsignerOptions, boolean debug) {
    if (fname == null) { throw new IllegalArgumentException("JAR FileName cannot be null"); }
    if (midas == null) { midas = Shell.getSharedMidasContext(); }

    // JC4: While it is possible to sign a JAR file programmatically
    //     (see http://www.onjava.com/pub/a/onjava/2001/04/12/signing_jar.html), it is not clean or easy. This is due to
    //     the fact that allowing users to potentially alter security privileges at run-time is generally discouraged.
    if (!FileUtil.isLocalFileName(fname.toString())) {
      throw new MidasException("JarFile.signJarFile(...): Can only sign JAR files on the local " + "disk; given '"+fname+"'.");
    }
    final String sep = java.io.File.separator; // path separator '/' on unix '\' on windows
    String jarFilename = FileUtil.toLocalFileName(fname.toString());

    if (jarFilename.length() == 0) {
      throw new MidasException("JarResource.signJar(..): Can only sign JAR, no file name given.");
    }

    String keystoreFilePath, storePass = null, keyAlias, keyPass = null, optionsFromCfgTab = null;
    if (customKeystoreCfg != null) { // since 3.5.1
      keystoreFilePath = customKeystoreCfg.getS("KEYSTOREFILE"); // required
      keyAlias         = customKeystoreCfg.getS("KEYALIAS");     // required
      storePass        = customKeystoreCfg.getS("KEYSTOREPASS"); // required (really optional, but executed jarsigner
                                                                 //   command via Foreign.runInternal() will prompt for password and
                                                                 //   wait forever on input from user but STDIN is NOT redirected
      keyPass          = customKeystoreCfg.getS("KEYPASS");      // optional (but required (without a check) if different than storepass)
      optionsFromCfgTab= customKeystoreCfg.getS("MOREOPTIONS");  // optional custom jarsigner options to add to cmdline
    } else { // default to one in SYS
      keystoreFilePath = Shell.getOptPath("sys","cfg")+"keystore";
      storePass = "nmsign";
      keyAlias  = "nmsign";
    }
    // check that required fields are set
    if (keystoreFilePath == null || keystoreFilePath.isEmpty()) {
      throw new IllegalArgumentException("KEYSTOREFILE= is required! given " + keystoreFilePath);
    }
    if (keyAlias == null || keyAlias.isEmpty()) {
      throw new IllegalArgumentException("KEYALIAS= is required! given " + keyAlias);
    }
    if (storePass == null || storePass.isEmpty()) {
      throw new IllegalArgumentException("KEYSTOREPASS= is required! given " + storePass);
    }

    StringBuilder cmdline = new StringBuilder(256);
    String javahome = Shell.getProperty("JAVAHOME");
    cmdline.append(FileName.terminatePath(javahome)).append("bin").append(sep).append("jarsigner");

    if (opt == null) opt = "SYS";                   // if opt not specified, default to SYS for BUG 2999
    String optionsFromBuildProps = Build.getPropertyStatic(opt, "jarsigner.flags"); // since 3.5.2 for BUG 2803
    if (optionsFromBuildProps != null && optionsFromBuildProps.length() > 0) {
      cmdline.append(' ').append(optionsFromBuildProps);
    }

    cmdline.append(" -keystore ").append(keystoreFilePath);
    if (storePass != null && !storePass.isEmpty()) {
      cmdline.append(" -storepass");
      // next check is for Java 7+ to use password from environment variable or file (need to keep option as one, e.g. -storepass:env)
      if (!storePass.startsWith(":env ") && !storePass.startsWith(":file ")) cmdline.append(' ');
      cmdline.append(storePass);
    }
    if (keyPass != null && !keyPass.isEmpty()) {    // semi-optional, jarsigner will use storepass if possible
      cmdline.append(" -keypass ").append(keyPass); // WARN: otherwise it prompts for it but will wait forever...
    }
    if (optionsFromCfgTab != null) {
      cmdline.append(' ').append(optionsFromCfgTab);
    }
    if (customJarsignerOptions != null) {
      cmdline.append(' ').append(customJarsignerOptions);
    }
    // <jarfile> <keyAias> MUST be on last on jarsigner command line
    cmdline.append(' ').append(jarFilename);
    cmdline.append(' ').append(keyAlias);

    if (javahome == null) { // since 3.3.0
      throw new MidasException("JAVAHOME OS env not set to JDK directory, cannot run: "+cmdline);
    }

    if (debug) { // since 3.5.1
      midas.type("Running following command to sign jar: " + cmdline);
    }
    if (Foreign.runInternal(midas, cmdline.toString(), true) != 0) {
      if ((Shell.getJavaMajorVersion() == 8) && (Shell.getJavaUpdateVersion() < 312) &&
          (customKeystoreCfg == null)) {
        throw new MidasException("JarFile: Default JAR signing with JDK 8 requires update version 312 or greater, filename='"
                                 +jarFilename + "' command='"+cmdline+"'.");
      }
      else {
        throw new MidasException("JarFile: Unknown error while signing JAR, filename='"+jarFilename + "' command='"+cmdline+"'.");
      }
    }
  }

  /** Creates a new jar file, but does not sign it. This is the same as
      <code>createJarFile(midas,jarFile,null,names,files,false)</code>. The signature of this
      method matches the {@link ZipFile#createZipFile(Midas,FileName,String[],FileName[],boolean)}
      method.
      @param midas   The midas context to use.
      @param jarFile The name of the jar file to create.
      @param names   The names of the files to write, as shown in the zip file. Names must use
                     forward slashes "/" not backward slashes "\" on all systems.
      @param files   The names of the files to write, as on disk. (Must be same length as names).
      @param verbose Show verbose output.
      @see ZipFile#createZipFile(Midas,FileName,String[],FileName[],boolean)
   */
  public static void createJarFile (Midas midas, FileName jarFile, String[] names, FileName[] files, boolean verbose) { // TODO: deprecate?
    createJarFile(midas, jarFile, null, names, files, verbose, false);
  }

  /** Creates a new jar file. The files added to the jar are listed twice using the same syntax as
      {@link ZipFile#createZipFile(Midas,FileName,String[],FileName[],boolean)}. <br>
      <br>
      <b>Important Note:</b> If you are creating a {@link Manifest} to pass in to this method, be
      sure that the "Main Attribute" {@link java.util.jar.Attributes.Name#MANIFEST_VERSION} is set
      properly, otherwise the manifest will not be written to the file (this is a limitation is
      imposed by the {@link JarOutputStream} class).
      @param midas    The midas context to use.
      @param jarFile  The name of the jar file to create.
      @param manifest The JAR file manifest to include.
      @param names    The names of the files to write, as shown in the zip file. Names must use
                      forward slashes "/" not backward slashes "\" on all systems.
      @param files    The names of the files to write, as on disk. (Must be same length as names).
      @param verbose  Show verbose output and debug output.
      @param sign     Should the jar file be signed (using <code>signJarFile(midas,jarFile)</code>)
                      after it is created?
      @throws IllegalStateException If asked to sign a JAR and {@code SSLUtilities.initSSLContext(..)}
                      has not been used to load a valid signing certificate.
      @see SSLUtilities#signJarFile
      @since NeXtMidas 3.7.3 (Note: This method was present earlier in NeXtMidas versions
                      but was marked {@link InternalUseOnly} and was not part of the
                      public API.  It should be noted that in NeXtMidas 3.7.2 and earlier
                      the mechanism for signing the JAR file differed significantly.
   */
  @ProvisionalUseOnly ("This improved method of signing jars is not yet fully tested")
  @SuppressWarnings({"UseSpecificCatch", "BroadCatchBlock", "TooBroadCatch"})
  public static void createJarFile (Midas midas, FileName jarFile, Manifest manifest, String[] names,
                                   FileName[] files, boolean verbose, boolean sign) {
    try (FileOutputStream     dest = new FileOutputStream(jarFile.toString());
         BufferedOutputStream bos = new BufferedOutputStream(dest);
         JarOutputStream      out = (manifest == null) ? new JarOutputStream(bos)
                                                       : new JarOutputStream(bos,manifest)) {
      ZipFile.createZipFile(midas,out,names,files,true,verbose);
      out.flush();
    }
    catch (Exception e) {
      throw new MidasException("JarFile: Error unable to create jar file", e);
    }

    if (sign) {
      // Note that we avoid doing the JAR signing as part of a single create-and-sign
      // operation since the JAR signing requires multiple passes through the file,
      // and since it is possible for the contents of the JAR file to exceed the available JVM memory
      SSLUtilities.signJarFile(jarFile);
    }
  }

  /** Creates a new jar file. The files added to the jar are listed twice using the same syntax as
    {@link ZipFile#createZipFile(Midas,FileName,String[],FileName[],boolean)}. <br>
    <br>
    <b>Important Note:</b> If you are creating a {@link Manifest} to pass in to this method, be
    sure that the "Main Attribute" {@link java.util.jar.Attributes.Name#MANIFEST_VERSION} is set
    properly, otherwise the manifest will not be written to the file (this is a limitation is
    imposed by the {@link JarOutputStream} class).
    @param midas    The midas context to use.
    @param jarFile  The name of the jar file to create.
    @param manifest The JAR file manifest to include.
    @param names    The names of the files to write, as shown in the zip file. Names must use
                    forward slashes "/" not backward slashes "\" on all systems.
    @param files    The names of the files to write, as on disk. (Must be same length as names).
    @param verbose  Show verbose output.
    @param sign     Should the jar file be signed (using <code>signJarFile(midas,jarFile)</code>) after it is created?
    @param opt      (optional) option tree name (null to use SYS's build.props). Since 3.5.2.
    @param customKeystoreCfg (optional) custom keystore configuration table (keys are:
               KEYSTOREFILE=, KEYSTOREPASS=, KEYALIAS=, KEYPASS=). null to use default in SYS. Since 3.5.1.
    @param customJarsignerOptions (optional) custom options to pass to jarsigner (this is appended to the end of
               all existing options before jarFile and keyAlias). Since 3.5.1.
    @param debug true to print jarsigner command line that will be executed, false for none
    @since NeXtMidas 3.5.1 (extended original createJarFile(..) with additional params to use custom keystore)
  */
  @InternalUseOnly("Since initial version")
  public static void createJarFile(Midas midas, FileName jarFile, Manifest manifest, String[] names,
                                   FileName[] files, boolean verbose, boolean sign,
                                   String opt, Table customKeystoreCfg, String customJarsignerOptions, boolean debug) {

    try {
      FileOutputStream     dest = new FileOutputStream(jarFile.toString());
      BufferedOutputStream bos  = new BufferedOutputStream(dest);
      JarOutputStream      out  = (manifest==null)? new JarOutputStream(bos)
                                                  : new JarOutputStream(bos,manifest);

      ZipFile.createZipFile(midas,out,names,files,true,verbose);
    } catch (Exception e) {
      throw new MidasException("JarFile: Error unable to create jar file",e);
    }
    if (sign) {
      if (verbose) {
        String moreDetails = "";
        if (customKeystoreCfg != null) {
          moreDetails = " with custom keystore file " + customKeystoreCfg.getS("KEYSTOREFILE");
        }
        midas.info("Signing JAR file " + jarFile + moreDetails);
      }
      signJarFile(midas,jarFile, opt, customKeystoreCfg, customJarsignerOptions, debug);
    }
  }

}
