/*
 * Copyright (C) 2015 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package libcore.io;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.JarURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLStreamHandler;
import java.util.jar.JarFile;
import java.util.zip.ZipEntry;
import sun.net.www.ParseUtil;
import sun.net.www.protocol.jar.Handler;

/**
 * A {@link URLStreamHandler} for a specific class path {@link JarFile}. This class avoids the need
 * to open a jar file multiple times to read resources if the jar file can be held open. The
 * {@link URLConnection} objects created are a subclass of {@link JarURLConnection}.
 *
 * <p>Use {@link #getEntryUrlOrNull(String)} to obtain a URL backed by this stream handler.
 */
public class ClassPathURLStreamHandler extends Handler {
  private final String fileUri;
  private final JarFile jarFile;

  public ClassPathURLStreamHandler(String jarFileName) throws IOException {
    this(jarFileName, /* enableZipPathValidator */ true);
  }

  /** @hide */
  public ClassPathURLStreamHandler(String jarFileName, boolean enableZipPathValidator) throws
          IOException {
    jarFile = new JarFile(jarFileName, enableZipPathValidator, /* verify */ true);

    // File.toURI() is compliant with RFC 1738 in always creating absolute path names. If we
    // construct the URL by concatenating strings, we might end up with illegal URLs for relative
    // names.
    this.fileUri = new File(jarFileName).toURI().toString();
  }

  /**
   * Returns a URL backed by this stream handler for the named resource, or {@code null} if the
   * entry cannot be found under the exact name presented.
   */
  public URL getEntryUrlOrNull(String entryName) {
    if (jarFile.getEntry(entryName) != null) {
      try {
        // Encode the path to ensure that any special characters like # survive their trip through
        // the URL. Entry names must use / as the path separator.
        String encodedName = ParseUtil.encodePath(entryName, false);
        return new URL("jar", null, -1, fileUri + "!/" + encodedName, this);
      } catch (MalformedURLException e) {
        throw new RuntimeException("Invalid entry name", e);
      }
    }
    return null;
  }

  /**
   * Returns true if an entry with the specified name exists and is stored (not compressed),
   * and false otherwise.
   */
  public boolean isEntryStored(String entryName) {
    ZipEntry entry = jarFile.getEntry(entryName);
    return entry != null && entry.getMethod() == ZipEntry.STORED;
  }

  @Override
  protected URLConnection openConnection(URL url) throws IOException {
    return new ClassPathURLConnection(url);
  }

  /** Used from tests to indicate this stream handler is finished with. */
  public void close() throws IOException {
    jarFile.close();
  }

  private class ClassPathURLConnection extends JarURLConnection {
    // The JarFile instance can be shared across URLConnections and should not be closed when it is:
    //
    // Sharing occurs if getUseCaches() is true when connect() is called (which can take place
    // implicitly). useCachedJarFile records the state of sharing at connect() time.
    // useCachedJarFile == true is the common case. If developers call getJarFile().close() when
    // sharing is enabled then it will affect other users (current and future) of the shared
    // JarFile.
    //
    // Developers could call ClassLoader.findResource().openConnection() to get a URLConnection and
    // then call setUseCaches(false) before connect() to prevent sharing. The developer must then
    // call getJarFile().close() or close() on the inputStream from getInputStream() will do it
    // automatically. This is likely to be an extremely rare case.
    //
    // Most developers are not expecting to deal with the lifecycle of the underlying JarFile object
    // at all. The presence of the getJarFile() method and setUseCaches() forces us to consider /
    // handle it.
    private JarFile connectionJarFile;

    private ZipEntry jarEntry;
    private InputStream jarInput;
    private boolean closed;

    /**
     * Indicates the behavior of the {@link #jarFile}. If true, the reference is shared and should
     * not be closed. If false, it must be closed.
     */
    private boolean useCachedJarFile;


    public ClassPathURLConnection(URL url) throws MalformedURLException {
      super(url);
    }

    @Override
    public void connect() throws IOException {
      if (!connected) {
        this.jarEntry = jarFile.getEntry(getEntryName());
        if (jarEntry == null) {
          throw new FileNotFoundException(
              "URL does not correspond to an entry in the zip file. URL=" + url
              + ", zipfile=" + jarFile.getName());
        }
        useCachedJarFile = getUseCaches();
        connected = true;
      }
    }

    @Override
    public JarFile getJarFile() throws IOException {
      connect();

      // We do cache in the surrounding class if useCachedJarFile is true to
      // preserve garbage collection semantics and to avoid leak warnings.
      if (useCachedJarFile) {
        connectionJarFile = jarFile;
      } else {
        connectionJarFile = new JarFile(jarFile.getName());
      }
      return connectionJarFile;
    }

    @Override
    public InputStream getInputStream() throws IOException {
      if (closed) {
        throw new IllegalStateException("JarURLConnection InputStream has been closed");
      }
      connect();
      if (jarInput != null) {
        return jarInput;
      }
      return jarInput = new FilterInputStream(jarFile.getInputStream(jarEntry)) {
        @Override
        public void close() throws IOException {
          super.close();
          // If the jar file is not cached then closing the input stream will close the
          // URLConnection and any JarFile returned from getJarFile(). If the jar file is cached
          // we must not close it because it will affect other URLConnections.
          if (connectionJarFile != null && !useCachedJarFile) {
            connectionJarFile.close();
            closed = true;
          }
        }
      };
    }

    /**
     * Returns the content type of the entry based on the name of the entry. Returns
     * non-null results ("content/unknown" for unknown types).
     *
     * @return the content type
     */
    @Override
    public String getContentType() {
      String cType = guessContentTypeFromName(getEntryName());
      if (cType == null) {
        cType = "content/unknown";
      }
      return cType;
    }

    @Override
    public int getContentLength() {
      try {
        connect();
        return (int) getJarEntry().getSize();
      } catch (IOException e) {
        // Ignored
      }
      return -1;
    }
  }
}
