/*
 * Copyright 2014 Google Inc.
 *
 * 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.gwt.dev.javac;

import com.google.gwt.core.ext.TreeLogger;
import com.google.gwt.core.ext.TreeLogger.Type;
import com.google.gwt.core.ext.UnableToCompleteException;
import com.google.gwt.dev.jjs.impl.GwtAstBuilder;
import com.google.gwt.dev.util.CompilerVersion;
import com.google.gwt.dev.util.StringInterningObjectInputStream;
import com.google.gwt.dev.util.log.perf.SimpleEvent;
import com.google.gwt.thirdparty.guava.common.annotations.VisibleForTesting;
import com.google.gwt.thirdparty.guava.common.collect.Lists;
import com.google.gwt.thirdparty.guava.common.io.Closeables;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.EOFException;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.nio.file.Files;
import java.util.Collections;
import java.util.List;

/**
 * The directory containing persistent unit cache files.
 * (Helper class for {@link PersistentUnitCache}.)
 */
class PersistentUnitCacheDir {

  private static final String DIRECTORY_NAME = "gwt-unitCache";
  private static final String CACHE_FILE_PREFIX = "gwt-unitCache-";

  static final String CURRENT_VERSION_CACHE_FILE_PREFIX =
      CACHE_FILE_PREFIX + CompilerVersion.getHash();

  private final TreeLogger logger;
  private final File dir;
  private final String filePrefix;

  // Non-null when a a cache file is open for writing. (Always true in normal operation.)
  private OpenFile openFile;

  /**
   * Finds the child directory where the cache files will be stored and opens a new cache
   * file for appending.
   */
  PersistentUnitCacheDir(TreeLogger logger, File parentDir, String cacheFilePrefix)
      throws UnableToCompleteException {
    this.logger = logger;
    this.filePrefix = CURRENT_VERSION_CACHE_FILE_PREFIX + "-" + cacheFilePrefix + "-";

    /*
     * We must canonicalize the path here, otherwise we might set cacheDirectory
     * to something like "/path/to/x/../gwt-unitCache". If this were to happen,
     * the mkdirs() call below would create "/path/to/gwt-unitCache" but
     * not "/path/to/x".
     * Further accesses via the uncanonicalized path will fail if "/path/to/x"
     * had not been created by other means.
     *
     * Fixes issue 6443
     */
    try {
      parentDir = parentDir.getCanonicalFile();
    } catch (IOException e) {
      logger.log(TreeLogger.WARN, "Can't get canonical directory for "
          + parentDir.getAbsolutePath(), e);
      throw new UnableToCompleteException();
    }

    dir = chooseCacheDir(parentDir);
    if (!dir.isDirectory() && !dir.mkdirs()) {
      logger.log(TreeLogger.WARN, "Can't create directory: " + dir.getAbsolutePath());
      throw new UnableToCompleteException();
    }

    if (!dir.canRead()) {
      logger.log(Type.WARN, "Can't read directory: " + dir.getAbsolutePath());
      throw new UnableToCompleteException();
    }

    logger.log(TreeLogger.TRACE, "Persistent unit cache dir set to: " + dir.getAbsolutePath());

    openFile = new OpenFile(logger, createEmptyCacheFile(logger, dir, filePrefix));
  }

  /**
   * Returns the absolute path of the directory where cache files are stored.
   */
  String getPath() {
    return dir.getAbsolutePath();
  }

  /**
   * Returns the number of files written to the cache directory and closed.
   */
  synchronized int getClosedCacheFileCount() {
    return selectClosedFiles(listFiles(filePrefix)).size();
  }

  /**
   * Load everything cached on disk into memory.
   */
  synchronized void loadUnitMap(PersistentUnitCache destination) {
    if (logger.isLoggable(TreeLogger.TRACE)) {
      logger.log(TreeLogger.TRACE, "Looking for previously cached Compilation Units in "
          + getPath());
    }
    try (SimpleEvent ignored = new SimpleEvent("PersistentUnitCacheDir.loadUnitMap")) {
      List<File> files = selectClosedFiles(listFiles(filePrefix));
      for (File cacheFile : files) {
        loadOrDeleteCacheFile(cacheFile, destination);
      }
    }
  }

  /**
   * Delete all cache files in the directory except for the currently open file.
   */
  synchronized void deleteClosedCacheFiles() {
    logger.log(TreeLogger.TRACE, "Deleting cache files from " + dir);

    try (SimpleEvent ignored = new SimpleEvent("PersistentUnitCacheDir.deleteClosedCacheFiles")) {
      // We want to delete cache files from previous versions as well.
      List<File> allVersionsList = listFiles(CACHE_FILE_PREFIX);
      int deleteCount = 0;
      for (File candidate : allVersionsList) {
        if (deleteUnlessOpen(candidate)) {
          deleteCount++;
        }
      }

      logger.log(TreeLogger.TRACE, "Deleted " + deleteCount + " cache files from " + dir);
    }
  }

  /**
   * Closes the current cache file and opens a new one.
   */
  synchronized void rotate() throws UnableToCompleteException {
    logger.log(Type.TRACE, "Rotating persistent unit cache");
    if (openFile != null) {
      openFile.close(logger);
      openFile = null;
    }
    openFile = new OpenFile(logger, createEmptyCacheFile(logger, dir, filePrefix));
  }

  /**
   * Deletes the given file unless it's currently open for writing.
   */
  synchronized boolean deleteUnlessOpen(File cacheFile) {
    if (isOpen(cacheFile)) {
      return false;
    }
    logger.log(Type.TRACE, "Deleting file: " + cacheFile);
    boolean deleted = cacheFile.delete();
    if (!deleted) {
      logger.log(Type.WARN, "Unable to delete file: " + cacheFile);
    }
    return deleted;
  }

  /**
   * Writes a compilation unit to the disk cache.
   */
  synchronized void writeUnit(CompilationUnit unit) throws UnableToCompleteException {
    if (openFile == null) {
      logger.log(Type.TRACE, "Skipped writing compilation unit to cache because no file is open");
      return;
    }
    openFile.writeUnit(logger, unit);
  }

  /**
   * Closes the file where cache entries are written.
   * (This should only be called at shutdown.)
   */
  synchronized void closeCurrentFile() {
    if (openFile != null) {
      openFile.close(logger);
      openFile = null;
    }
  }

  @VisibleForTesting
  static File chooseCacheDir(File parentDir) {
    return new File(parentDir, DIRECTORY_NAME);
  }

  private boolean isOpen(File f) {
    return openFile != null && openFile.file.equals(f);
  }

  /**
   * Loads all the units in a cache file into the given cache.
   * Delete it if unable to read it.
   */
  private void loadOrDeleteCacheFile(File cacheFile, PersistentUnitCache destination) {
    FileInputStream fis = null;
    BufferedInputStream bis = null;
    ObjectInputStream inputStream = null;

    boolean ok = false;
    int unitsLoaded = 0;
    try {
      fis = new FileInputStream(cacheFile);
      bis = new BufferedInputStream(fis);
      /*
       * It is possible for the next call to throw an exception, leaving
       * inputStream null and fis still live.
       */
      inputStream = new StringInterningObjectInputStream(bis);

      // Read objects until we get an EOF exception.
      while (true) {
        CachedCompilationUnit unit = (CachedCompilationUnit) inputStream.readObject();
        if (unit == null) {
          // Won't normally get here. Not sure why this check was here before.
          logger.log(Type.WARN, "unexpected null in cache file: " + cacheFile);
          break;
        }
        if (unit.getTypesSerializedVersion() != GwtAstBuilder.getSerializationVersion()) {
          continue;
        }
        destination.maybeAddLoadedUnit(unit);
        unitsLoaded++;
      }

    } catch (EOFException ignored) {
      // This is a normal exit. Go on to the next file.
      ok = true;
    } catch (IOException e) {
      logger.log(TreeLogger.TRACE, "Ignoring and deleting cache log "
          + cacheFile.getAbsolutePath() + " due to read error.", e);
    } catch (ClassNotFoundException e) {
      logger.log(TreeLogger.TRACE, "Ignoring and deleting cache log "
          + cacheFile.getAbsolutePath() + " due to deserialization error.", e);
    } finally {
      Closeables.closeQuietly(inputStream);
      Closeables.closeQuietly(bis);
      Closeables.closeQuietly(fis);
    }

    if (ok) {
      logger.log(TreeLogger.TRACE, "Loaded " + unitsLoaded +
          " units from cache file: " + cacheFile.getName());
    } else {
      deleteUnlessOpen(cacheFile);
      logger.log(TreeLogger.TRACE, "Loaded " + unitsLoaded +
          " units from invalid cache file before deleting it: " + cacheFile.getName());
    }
  }

  /**
   * Lists files in the cache directory that start with the given prefix.
   *
   * <p>The files will be sorted according to {@link java.io.File#compareTo}, which
   * differs on Unix versus Windows, but is good enough to sort by age
   * for the names we use.</p>
   */
  private List<File> listFiles(String prefix) {
    File[] files = dir.listFiles();
    if (files == null) {
      // Shouldn't happen, just satisfying null check warning.
      return Collections.emptyList();
    }
    List<File> out = Lists.newArrayList();
    for (File file : files) {
      if (file.getName().startsWith(prefix)) {
        out.add(file);
      }
    }
    Collections.sort(out);
    return out;
  }

  /**
   * Removes the currently open file from a list of files.
   * @return the new list.
   */
  private List<File> selectClosedFiles(Iterable<File> fileList) {
    List<File> closedFiles = Lists.newArrayList();
    for (File file : fileList) {
      if (!isOpen(file)) {
        closedFiles.add(file);
      }
    }
    return closedFiles;
  }

  /**
   * Creates a new, empty file with a name based on the current system time.
   */
  private static File createEmptyCacheFile(TreeLogger logger, File dir, String filePrefix)
      throws UnableToCompleteException {
    File newFile = null;
    long timestamp = System.currentTimeMillis();
    try {
      do {
        newFile = new File(dir, filePrefix + String.format("%016X", timestamp++));
      } while (!newFile.createNewFile());
    } catch (IOException ex) {
      logger.log(TreeLogger.WARN, "Can't create new cache log file "
          + newFile.getAbsolutePath() + ".", ex);
      throw new UnableToCompleteException();
    }

    if (!newFile.canWrite()) {
      logger.log(TreeLogger.WARN, "Can't write to new cache log file "
          + newFile.getAbsolutePath() + ".");
      throw new UnableToCompleteException();
    }

    return newFile;
  }

  /**
   * The current file and stream being written to by the persistent unit cache, if any.
   *
   * <p>Not thread safe. (The parent class handles concurrency.)
   */
  private static class OpenFile {
    private final File file;
    private final ObjectOutputStream stream;
    private int unitsWritten = 0;

    /**
     * Opens a file for writing compilation units.
     * Overwrites the file (it's typically empty).
     * A cache file may not already be open.
     */
    OpenFile(TreeLogger logger, File toOpen)
        throws UnableToCompleteException {
      logger.log(Type.TRACE, "Opening cache file: " + toOpen);
      ObjectOutputStream newStream = openObjectStream(logger, toOpen);

      this.file = toOpen;
      this.stream = newStream;
      unitsWritten = 0;
    }

    /**
     * Writes a compilation unit to the currently open file, if any.
     * @return true if written
     * @throws UnableToCompleteException if the file was open but we can't append.
     */
    boolean writeUnit(TreeLogger logger, CompilationUnit unit)
        throws UnableToCompleteException {
      try {
        stream.writeObject(unit);
        unitsWritten++;
        return true;
      } catch (IOException e) {
        logger.log(TreeLogger.ERROR, "Error saving compilation unit to cache file: " + file, e);
        throw new UnableToCompleteException();
      }
    }

    /**
     * Closes the current file and deletes it if it's empty.
     */
    void close(TreeLogger logger) {
      logger.log(Type.TRACE,
          "Closing cache file: " + file + " (" + unitsWritten + " units written)");

      try {
        stream.close();
      } catch (IOException e) {
        logger.log(Type.WARN, "Error closing compilation unit cache file " + file, e);
      }

      if (unitsWritten == 0) {
        // Remove useless empty file.
        logger.log(Type.TRACE, "Deleting empty file: " + file);
        boolean deleted = file.delete();
        if (!deleted) {
          logger.log(Type.INFO, "Couldn't delete persistent unit cache file: " + file);
        }
      }
    }

    private static ObjectOutputStream openObjectStream(TreeLogger logger, File file)
        throws UnableToCompleteException {

      FileOutputStream fstream = null;
      try {
        fstream = new FileOutputStream(file);
        return new ObjectOutputStream(new BufferedOutputStream(fstream));
      } catch (IOException e) {
        logger.log(Type.ERROR, "Can't open persistent unit cache file", e);
        try {
          if (fstream != null) {
            fstream.close();
            if (file.exists()) {
              Files.delete(file.toPath());
            }
          }
        } catch (IOException ignored) {
          // We can't handle this, and already logged an error
        }
        throw new UnableToCompleteException();
      }
    }
  }
}
