// Copyright 2014 The Bazel Authors. All rights reserved.
//
// 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 com.google.devtools.build.lib.vfs;

import com.google.common.base.Predicate;
import com.google.devtools.build.lib.concurrent.ThreadSafety.ThreadSafe;
import com.google.devtools.build.lib.util.Preconditions;
import com.google.devtools.build.lib.vfs.Path.PathFactory;
import com.google.devtools.build.lib.vfs.Path.PathFactory.TranslatedPath;
import java.io.Closeable;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

/**
 * A FileSystem that provides a read-only filesystem view on a zip file.
 * Inherits the constraints imposed by ReadonlyFileSystem.
 */
@ThreadSafe
public class ZipFileSystem extends ReadonlyFileSystem implements Closeable {

  private final File tempFile;  // In case this needs to be written to the file system
  private final ZipFile zipFile;
  private boolean open;

  /**
   * The sole purpose of this field is to hold a strong reference to all leaf
   * {@link Path}s which have a non-null "entry" field, preventing them from
   * being garbage-collected.  (The leaf paths hold string references to their
   * parents, so we don't need to include them here.)
   *
   * <p>This is necessary because {@link Path}s may be recycled when they
   * become unreachable, but the ZipFileSystem uses them to hold the {@link
   * ZipEntry} for that path, if any.  Without this additional strong
   * reference, ZipEntries would seem to "disappear" during garbage collection.
   */
  @SuppressWarnings("unused")
  private final Object paths;

  /**
   * Constructs a ZipFileSystem from a zip file identified with a given path.
   */
  public ZipFileSystem(Path zipPath) throws IOException {
    if (!zipPath.exists()) {
      throw new FileNotFoundException(String.format("File '%s' does not exist", zipPath));
    }

    File file = zipPath.getPathFile();
    if (!file.exists()) {
      // If the File says that it does not exist but the Path says that it does, we are probably
      // dealing with a FileSystem that does not represent the actual file system we are running
      // under. Then we copy the Path into a temporary File.
      tempFile = File.createTempFile("bazel.test.", ".tmp");
      file = tempFile;
      byte[] contents = FileSystemUtils.readContent(zipPath);
      try (OutputStream os = new FileOutputStream(tempFile)) {
        os.write(contents);
      }
    } else {
      tempFile = null;
    }

    if (!file.isFile()) {
      throw new IOException(String.format("'%s' is not a file", zipPath));
    }
    if (!file.canRead()) {
      throw new IOException(String.format("File '%s' is not readable", zipPath));
    }

    this.zipFile = new ZipFile(file);
    this.paths = populatePathTree();
    this.open = true;
  }

  // ZipPath extends Path with a set-once ZipEntry field.
  // TODO(bazel-team): (2009) Delete class ZipPath, and perform the
  // Path-to-ZipEntry lookup in {@link #zipEntry} and {@link
  // #getDirectoryEntries}.  Then this field becomes redundant.
  @ThreadSafe
  private static class ZipPath extends Path {

    private enum Factory implements PathFactory {
      INSTANCE {
        @Override
        public Path createRootPath(FileSystem filesystem) {
          Preconditions.checkArgument(filesystem instanceof ZipFileSystem);
          return new ZipPath((ZipFileSystem) filesystem);
        }

        @Override
        public Path createChildPath(Path parent, String childName) {
          Preconditions.checkState(parent instanceof ZipPath);
          return new ZipPath((ZipFileSystem) parent.getFileSystem(), childName, (ZipPath) parent);
        }

        @Override
        public TranslatedPath translatePath(Path parent, String child) {
          return new TranslatedPath(parent, child);
        }
      };
    }

    /**
     * Non-null iff this file/directory exists.  Set by setZipEntry for files
     * explicitly mentioned in the zipfile's table of contents, or implicitly
     * an ancestor of them.
     */
    ZipEntry entry = null;

    // Root path.
    private ZipPath(ZipFileSystem fileSystem) {
      super(fileSystem);
    }

    // Non-root paths.
    private ZipPath(ZipFileSystem fileSystem, String name, ZipPath parent) {
      super(fileSystem, name, parent);
    }

    void setZipEntry(ZipEntry entry) {
      if (this.entry != null) {
        throw new IllegalStateException("setZipEntry(" + entry
                                        + ") called twice!");
      }
      this.entry = entry;

      // Ensure all parents of this path have a directory ZipEntry:
      for (ZipPath path = (ZipPath) getParentDirectory();
           path != null && path.entry == null;
           path = (ZipPath) path.getParentDirectory()) {
        // Note, the ZipEntry for the root path is called "//", but that's ok.
        path.setZipEntry(new ZipEntry(path + "/")); // trailing "/" => isDir
      }
    }
  }

  /**
   * Scans the Zip file and associates a ZipEntry with each filename
   * (ZipPath) that is mentioned in the table of contents.  Returns a
   * collection of all corresponding Paths.
   */
  private Collection<Path> populatePathTree() {
    Collection<Path> paths = new ArrayList<>();
    for (ZipEntry entry : Collections.list(zipFile.entries())) {
      PathFragment frag = new PathFragment(entry.getName());
      Path path = rootPath.getRelative(frag);
      paths.add(path);
      ((ZipPath) path).setZipEntry(entry);
    }
    return paths;
  }

  @Override
  public String getFileSystemType(Path path) {
    return "zipfs";
  }

  @Override
  protected PathFactory getPathFactory() {
    return ZipPath.Factory.INSTANCE;
  }

  /** Returns the ZipEntry associated with a given path name, if any. */
  private static ZipEntry zipEntry(Path path) {
    return ((ZipPath) path).entry;
  }

  /** Like zipEntry, but throws FileNotFoundException unless path exists. */
  private static ZipEntry zipEntryNonNull(Path path)
      throws FileNotFoundException {
    ZipEntry zipEntry = zipEntry(path);
    if (zipEntry == null) {
      throw new FileNotFoundException(path + " (No such file or directory)");
    }
    return zipEntry;
  }

  @Override
  protected InputStream getInputStream(Path path) throws IOException {
    Preconditions.checkState(open);
    return zipFile.getInputStream(zipEntryNonNull(path));
  }

  @Override
  protected Collection<Path> getDirectoryEntries(Path path)
      throws IOException {
    Preconditions.checkState(open);
    zipEntryNonNull(path);
    final Collection<Path> result = new ArrayList<>();
    ((ZipPath) path).applyToChildren(new Predicate<Path>() {
        @Override
        public boolean apply(Path child) {
          if (zipEntry(child) != null) {
            result.add(child);
          }
          return true;
        }
      });
    return result;
  }

  @Override
  protected boolean exists(Path path, boolean followSymlinks) {
    Preconditions.checkState(open);
    return zipEntry(path) != null;
  }

  @Override
  protected boolean isDirectory(Path path, boolean followSymlinks) {
    Preconditions.checkState(open);
    ZipEntry entry = zipEntry(path);
    return entry != null && entry.isDirectory();
  }

  @Override
  protected boolean isFile(Path path, boolean followSymlinks) {
    Preconditions.checkState(open);
    ZipEntry entry = zipEntry(path);
    return entry != null && !entry.isDirectory();
  }

  @Override
  protected boolean isSpecialFile(Path path, boolean followSymlinks) {
    Preconditions.checkState(open);
    return false;
  }

  @Override
  protected boolean isReadable(Path path) throws IOException {
    Preconditions.checkState(open);
    zipEntryNonNull(path);
    return true;
  }

  @Override
  protected boolean isWritable(Path path) throws IOException {
    Preconditions.checkState(open);
    zipEntryNonNull(path);
    return false;
  }

  @Override
  protected boolean isExecutable(Path path) throws IOException {
    Preconditions.checkState(open);
    zipEntryNonNull(path);
    return false;
  }

  @Override
  protected PathFragment readSymbolicLink(Path path) throws IOException {
    Preconditions.checkState(open);
    zipEntryNonNull(path);
    throw new NotASymlinkException(path);
  }

  @Override
  protected long getFileSize(Path path, boolean followSymlinks)
      throws IOException {
    Preconditions.checkState(open);
    return zipEntryNonNull(path).getSize();
  }

  @Override
  protected long getLastModifiedTime(Path path, boolean followSymlinks)
      throws FileNotFoundException {
    Preconditions.checkState(open);
    return zipEntryNonNull(path).getTime();
  }

  @Override
  protected boolean isSymbolicLink(Path path) {
    Preconditions.checkState(open);
    return false;
  }

  @Override
  protected FileStatus statIfFound(Path path, boolean followSymlinks) {
    Preconditions.checkState(open);
    try {
      return stat(path, followSymlinks);
    } catch (FileNotFoundException e) {
      return null;
    } catch (IOException e) {
      // getLastModifiedTime can only throw FileNotFoundException, which is what stat uses.
      throw new IllegalStateException (e);
    }
  }

  @Override
  public void close() {
    if (open) {
      close();
      if (tempFile != null) {
        tempFile.delete();
      }
      open = false;
    }
  }
}
