package nxm.sys.lib;

import java.io.BufferedInputStream;
import java.io.FilenameFilter;
import java.io.IOException;
import java.net.JarURLConnection;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;

import nxm.sys.inc.Installation;
import nxm.sys.inc.InternalUseOnly;
import nxm.sys.lib.CoreIO.IOOptions;

/** Resource for interacting with entries in a Jar File.
 *  @author  Jeff Schoen
 *  @author  J. Czechowski IV
 */
public class JarResource extends IOResource {
  private URL                 url; // Use of Jar/File (special syntax, see setURL(..))
  private BufferedInputStream in;  // Current input stream.
  private Boolean existCachedResult = null; // since 3.5.4 cache existence result (null, means we haven't checked)

  /** default constructor */
  public JarResource () {}

  @Override
  public String toString () {
    return "JarResource: " + getURL();
  }

  /** Sets the fully qualified URL of the resource.
   *  <pre>
   *    {@link java.net.JarURLConnection} requires a very specific format for specifying
   *    a file/dir in a URL. Some examples:
   *        jar:http://www.foo.com/bar/baz.jar!/COM/foo/Quux.class    ! Jar entry
   *        jar:file:/home/duke/duke.jar!/                            ! Jar File
   *        jar:http://www.foo.com/bar/baz.jar!/COM/foo/              ! Jar Directory
   *        jar:file:c:\nextmidas\jars\nxm-sys.jar!/nxm/sys/dat/      ! Jar Directory on Windows
   *    NeXtMidas is a little less restrictive and we try to fix the URL where possible.
   *    Some things we *try* to fix:
   *        - Omission of the '!' after the ".jar"
   *        - Omission of the root-level '/' (needs to have "!/")
   *        - Omission of "file:" for local JAR files
   *        - Omission of "jar:"
   *  </pre>
   *  @param urlstr The fully qualified URL of the resource.
   */
  @Override
  public void setURL (String urlstr) {
    String prev_urlstr = getURL();
    if (!urlstr.equals(prev_urlstr)) { // do below only if different than previous urlstr
      urlstr = urlstr.replace("\\", "/"); // convert to UNIX style url, since 3.7.3 for NXM-3099
      super.setURL("jar:", ".jar", new String[] { "!/" }, urlstr);
      try {
        this.url = (new URI(getURL())).toURL();
      }
      catch (MalformedURLException | URISyntaxException e) {
        throw new MidasException("JarResource: Error accessing JAR file URL="+getURL(), e);
      }
      existCachedResult = null;  // clear cached result - since 3.7.1 for NXM-2948
    }
  }

  /** Gets a connection to the JAR. Caller is responsible to release any open resources.
   *  @return JarURLConnection (never returns null)
   *  @throws MidasException if not able to open JarURLConnection or some other exception
   */
  @InternalUseOnly // this is meant to be package-private
  @SuppressWarnings({"BroadCatchBlock", "TooBroadCatch"})
  protected JarURLConnection getConnection () throws MidasException {
    URLConnection conn = null;
    try {
      conn = url.openConnection();
      if (getStateQualifier("DISABLEUSECACHES")) { // since 3.6.0 as part of BUG 2863
        conn.setUseCaches(false);
      }
      return (JarURLConnection)conn;

    }
    catch (ClassCastException e) {
      // This is extremely unlikely with a Java VM from Sun/Oracle, but could potentially
      // happen with another vendor's Java VM.
      throw new MidasException("JarResource: Unable to convert "+conn.getClass()
                             + " to JarURLConnection when given URL="+url, e);
    }
    catch (Exception e) {
      throw new MidasException("JarResource: Error connecting to URL="+url, e);
    }
  }

  /** Gets a entry in the JAR.
   *  WARN: use of this method leaks a resource b/c of leak in JarURLConnection.getJarEntry().
   *  @return JarEntry representing a JAR file entry,
   *          or null if unable to get JarURLConnection or JarEntry from the connection.
   *  @see JarURLConnection#getJarEntry()
   */
  @InternalUseOnly // this is meant to be package-private
  protected JarEntry getEntry () {
    try {
      JarURLConnection conn = getConnection();
      return (conn != null)? conn.getJarEntry(): null;
    }
    catch (IOException e) {
      // TODO: We should log this error...
      // Error in getJarEntry() or the entry does not exist (or was not specified).
      return null;
    }
  }

  /** Gets the JAR file.
   *  NOTE: caller MUST call {@link JarFile#close()} when done to release resource.
   *  @return JarFile to read the contents of a jar file,
   *          or null if unable to get JarURLConnection or JarFile from the connection (e.g. does not exists)
   *  @see JarURLConnection#getJarFile()
   */
  @InternalUseOnly // this is meant to be package-private
  protected java.util.jar.JarFile getFile () {
    try {
      JarURLConnection conn = getConnection();
      return (conn != null)? conn.getJarFile(): null;
    }
    catch (IOException e) {
      // TODO: We should log this error...
      return null; // Error in getJarFile(). return null for not found
    }
  }

  @Override
  public boolean exists () {
    //  return (getEntry() != null) || (getFile() != null); // prior to 3.5.3 before fix to BUG 2863
    // FYI: getEntry() which calls JarURLConnection.getJarEntry() returns getJarFile().getJarEntry(entryName);
    // We don't have to check the getJarEntry(), as the getJarFile() check throws a FileNotFoundException
    // since 3.5.4 new implementation for BUG 2863 (OpenJDK/IcedTea-Web [NetX] javaws error) and BUG 2897 (resource leak)
    if (existCachedResult != null) return existCachedResult;
    JarFile jarFile = getFile(); // since
    existCachedResult = (jarFile != null);
    if (existCachedResult && !Installation.ApplyBugFix.JARRESOURCE_ICEDTEA_WEB_WORKAROUND) { // disable fix for BUG 2863
      try {
        jarFile.close(); // close to prevent resource leak for BUG 2897
      }
      catch (IOException e) {
        if (M != null) M.printStackTrace(e);
        else           Shell.printStackTrace(e);
      }
    }
    return existCachedResult;
  }

  @Override
  @SuppressWarnings({"BroadCatchBlock", "TooBroadCatch", "UseSpecificCatch"})
  public boolean open () {
    isOpen = false;
    try {
      long             startOffset = offset; // Offset to start at (see seek(..) for details)
      JarURLConnection conn        = getConnection();

      if (dir <= DIR_INOUT) { // Input
        in = new BufferedInputStream(conn.getInputStream());
      }
      if (dir >= DIR_INOUT) { // Output
        // Output to a JAR file is not as simple as opening an output stream. It requires
        // a linear write of all data (no going back to write a BLUE File header) for all
        // files with a special ZipEntry added before each file. Oh, and it can only be
        // done on local random-access files.
        throw new MidasException("JarResource: Output not supported at this time, URL="+url);
      }

      offset = 0L;       // We are currently at the start of the new stream
      isOpen = true;     // File is now technically open (must be set before seek(..))
      seek(startOffset); // Seek to the desired start location
    }
    catch (Exception e) {
      // Same as FileResuorce - Ignore the exception and return isOpen
      // TODO: We should log this error...
      isOpen = false;
    }
    return isOpen;
  }

  @Override
  public boolean close () {
    boolean stat = closeIO(in);
    isOpen = false;
    in     = null;
    return stat;
  }

  /** Reopens the connection at the given seek offset. */
  private void reopenAtOffset (long off) {
    offset = off;
    close();
    open();
  }

  @Override
  public long getLength() {
    JarEntry entry = getEntry();
    return (entry != null)? entry.getSize() : 0L;
  }

  @Override
  public boolean seek (long off) {
    if (!isOpen) {
      offset = off; // will take affect when open() is called
    }
    else if (off == offset) {
      // Already here...
    }
    else if (off > offset) {
      // Move forward...
      try {
        long toSkip  = off-offset;
        long numSkip = in.skip(toSkip);

        if (numSkip != toSkip) {
          // Handle case with seek beyond end of file
          if (in.available()==0) {
            // Throw MidasIOException unless IOOption is set revert to just returning false
            if (M.io.isOptionSet(IOOptions.FileIONoAbort)){
              M.info("JarResource: seek location beyond end of file offset="+off+" URL="+url);
              return false;
            }
            else {
              throw new MidasIOException("JarResource: seek location beyond end of file offset="+off+" URL="+url);
            }
          }
          else {
            // This happens a lot when running our conf/baseline/interactive=false/alttestenv from a JAR
            // Skipping logging as often there is a summary warning elsewhere
            reopenAtOffset(off);
          }
        }
      }
      catch (IOException e) {
        if (e.getMessage().equals("Stream closed")) {
          // Not explicitly seen this case, but assume it will occur similar to what we see
          // frequently in read(..). See the comments in read(..) for details.
          M.warnStackTrace("Reopening "+url+". JarResource.seek resulted in", e);
          reopenAtOffset(off);
        }
        else {
          throw new MidasException("JarResource: Unable to seek to "+off+" URL="+url,e);
        }
      }
      offset = off;
    }
    else {
      // Move backwards...
      // Streams are not random-access. Need to close and then reopen.
      reopenAtOffset(off);
    }
    return true;
  }

  @Override
  public long avail () {
    if (in == null) return 0L;

    try {
      return in.available();
    }
    catch (IOException e) {
      // TODO: We should log this error...
      return 0L;
    }
  }

  @Override
  public int read (byte[] buf, int boff, int bytes) {
    int totRead     = 0;
    int reopenCount = 0;

    while (bytes > 0) {
      try {
        int numRead = in.read(buf,boff,bytes);

        if (numRead < 0) {
          // If we have already read some data, return that and EOF on the next call.
          // Otherwise just return EOF.
          return (totRead > 0)? totRead : numRead;
        }
        offset  += numRead;
        boff    += numRead;
        totRead += numRead;
        bytes   -= numRead;
      }
      catch (IOException e) {
        if (e.getMessage().equals("Stream closed")) {
          // This situation is fairly common. It would appear that if there is some delay
          // between reads that the connection will automatically close (even the delays
          // internal to running "PLOT world.shp" are enough to trigger it).

          // TODO: Should log this so we can track how often it comes up...
          if (reopenCount == 1024) {
            // We have never seen this, but want to have this here to prevent an infinite
            // loop calling reopenAtOffset(..). 1024 is just an arbitrary number, we want
            // something large enough to differentiate the occasional close from a case
            // where we are stuck in a loop.
            Shell.warning("JarResource: Connection to "+url+" closed, unable to complete read(..) call");
            return (totRead > 0)? totRead : CoreIO.ERROR;
          }
          else {
            reopenAtOffset(offset);
            reopenCount++;
          }
        }
        else {
          // TODO: Should log this...
          return (totRead > 0)? totRead : CoreIO.ERROR;
        }
      }
    }
    return totRead;
  }

  @Override
  public int write (byte[] buf, int boff, int bytes) {
    throw new MidasException("JarResource: Output not supported at this time, URL="+url);
  }

  @Override
  public String[] getEntryList (FilenameFilter filter) {
    JarURLConnection      conn    = getConnection();
    ArrayList<String>     list    = new ArrayList<>();
    JarFile               jarFile = getFile();
    Enumeration<JarEntry> entries = (jarFile != null) ? jarFile.entries() : null;

    // NOTE: Each entry in the JAR is listed as <path>/<filename>. For example, assume the AUX
    //       is pointing to "nxm-sys.jar/nxm/sys/dat/" then the "world.prm" will be listed as
    //       "nxm/sys/dat/world.prm". We need to exclude files in other directories and then
    //       trim the path off of the name.
    String entryName = conn.getEntryName();
    int    lenEntName;

    if (entryName == null) { // Flat ZIP file with no directory structure.
      entryName = "";
      lenEntName = 0;
    } else {
      lenEntName = entryName.length();
    }

    while ((entries != null) && entries.hasMoreElements()) {
      JarEntry entry = entries.nextElement();
      String   name  = entry.getName();

      if (name.startsWith(entryName)) {
        String fname = name.substring(lenEntName);

        if ((fname.length() > 0) && (fname.indexOf('/') < 0) && filter.accept(null, fname)) {
          list.add(fname);
        }
      }
    }

    String[] files = list.toArray(EMPTY_STRING_ARRAY);
    Arrays.sort(files);
    return files;
  }

  @Override // since NeXtMidas 3.3.0
  public long lastModified () {
    long utcSecs = 0;
    JarEntry jarEntry = getEntry();
    if (jarEntry != null) {
      utcSecs = jarEntry.getTime();
      if (utcSecs < 0 ) utcSecs = 0;
    }
    return utcSecs;
  }
}
