/*
 * Copyright 2009 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.util;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.RandomAccessFile;

/**
 * A nifty class that lets you squirrel away data on the file system. Write
 * once, read many times. Instance of this are thread-safe by way of internal
 * synchronization.
 *
 * Note that in the current implementation, the backing temp file will get
 * arbitrarily large as you continue adding things to it. There is no internal
 * GC or compaction.
 */
public class DiskCache {
  /**
   * For future thought: if we used Object tokens instead of longs, we could
   * actually track references and do GC/compaction on the underlying file.
   *
   * I considered using memory mapping, but I didn't see any obvious way to make
   * the map larger after the fact, which kind of defeats the infinite-append
   * design. At any rate, I measured the current performance of this design to
   * be so fast relative to what I'm using it for, I didn't pursue this further.
   */

  /**
   * A global shared Disk cache.
   */
  public static DiskCache INSTANCE = new DiskCache();

  private boolean atEnd = true;
  private final RandomAccessFile file;

  private DiskCache() {
    try {
      File temp = File.createTempFile("gwt", "byte-cache");
      temp.deleteOnExit();
      file = new RandomAccessFile(temp, "rw");
      file.setLength(0);
      registerShutdownHook();
    } catch (IOException e) {
      throw new RuntimeException("Unable to initialize byte cache", e);
    }
  }

  /**
   * Retrieve the underlying bytes.
   *
   * @param token a previously returned token
   * @return the bytes that were written
   */
  public synchronized byte[] readByteArray(long token) {
    try {
      atEnd = false;
      file.seek(token);
      int length = file.readInt();
      byte[] result = new byte[length];
      file.readFully(result);
      return result;
    } catch (IOException e) {
      throw new RuntimeException("Unable to read from byte cache", e);
    }
  }

  /**
   * Deserialize the underlying bytes as an object.
   *
   * @param <T> the type of the object to deserialize
   * @param token a previously returned token
   * @param type the type of the object to deserialize
   * @return the deserialized object
   */
  public <T> T readObject(long token, Class<T> type) {
    try {
      byte[] bytes = readByteArray(token);
      ByteArrayInputStream in = new ByteArrayInputStream(bytes);
      return Util.readStreamAsObject(in, type);
    } catch (ClassNotFoundException e) {
      throw new RuntimeException("Unexpected exception deserializing from disk cache", e);
    } catch (IOException e) {
      throw new RuntimeException("Unexpected exception deserializing from disk cache", e);
    }
  }

  /**
   * Read the underlying bytes as a String.
   *
   * @param token a previously returned token
   * @return the String that was written
   */
  public String readString(long token) {
    return Util.toString(readByteArray(token));
  }

  /**
   * Write the rest of the data in an input stream to disk. Note: this method
   * does not close the InputStream.
   *
   * @param in open stream containing the data to write to the disk cache.
   *
   * @return a token to retrieve the data later
   */
  public synchronized long transferFromStream(InputStream in) throws IOException {
    assert in != null;
    byte[] buf = Util.takeThreadLocalBuf();
    try {
      long position = moveToEndPosition();

      // Placeholder, we don't know the length yet.
      file.writeInt(-1);

      // Transfer all the bytes.
      int length = 0;
      int bytesRead;
      while ((bytesRead = in.read(buf)) != -1) {
        file.write(buf, 0, bytesRead);
        length += bytesRead;
      }

      // Now go back and fill in the length.
      file.seek(position);
      file.writeInt(length);
      // Don't eagerly seek the end, the next operation might be a read.
      atEnd = false;
      return position;
    } finally {
      Util.releaseThreadLocalBuf(buf);
    }
  }

  /**
   * Writes the underlying bytes into the specified output stream.
   *
   * @param token a previously returned token
   * @param out the stream to write into
   */
  public synchronized void transferToStream(long token, OutputStream out) throws IOException {
    byte[] buf = Util.takeThreadLocalBuf();
    try {
      atEnd = false;
      file.seek(token);
      int length = file.readInt();
      int bufLen = buf.length;
      while (length > bufLen) {
        int read = file.read(buf, 0, bufLen);
        length -= read;
        out.write(buf, 0, read);
      }
      while (length > 0) {
        int read = file.read(buf, 0, length);
        length -= read;
        out.write(buf, 0, read);
      }
    } finally {
      Util.releaseThreadLocalBuf(buf);
    }
  }

  /**
   * Write a byte array to disk.
   *
   * @return a token to retrieve the data later
   */
  public synchronized long writeByteArray(byte[] bytes) {
    try {
      long position = moveToEndPosition();
      file.writeInt(bytes.length);
      file.write(bytes);
      return position;
    } catch (IOException e) {
      throw new RuntimeException("Unable to write to byte cache", e);
    }
  }

  /**
   * Serialize an Object to disk.
   *
   * @return a token to retrieve the data later
   */
  public long writeObject(Object object) {
    try {
      ByteArrayOutputStream out = new ByteArrayOutputStream();
      Util.writeObjectToStream(out, object);
      return writeByteArray(out.toByteArray());
    } catch (IOException e) {
      throw new RuntimeException("Unexpected IOException on in-memory stream", e);
    }
  }

  /**
   * Write a String to disk as bytes.
   *
   * @return a token to retrieve the data later
   */
  public long writeString(String str) {
    return writeByteArray(Util.getBytes(str));
  }

  /**
   * Moves to the end of the file if necessary and returns the offset position.
   * Caller must synchronize.
   *
   * @return the offset position of the end of the file
   * @throws IOException
   */
  private long moveToEndPosition() throws IOException {
    // Get an end pointer.
    if (atEnd) {
      return file.getFilePointer();
    } else {
      long position = file.length();
      file.seek(position);
      atEnd = true;
      return position;
    }
  }

  /**
   * Register a shutdown hook to close the RandomAccessFile associated with the temp file.<br>
   * There is a known <a href="https://bugs.java.com/bugdatabase/view_bug.do?bug_id=4171239">bug</a>
   * in Windows that prevents the 'temp' file from being deleted by 'deleteOnExit'
   * (see {@link DiskCache#DiskCache()}) because it is still open by the RandomAccessFile.<br>
   * This hook forces the RandomAccessFile to be closed at shutdown to allow the correct
   * 'temp' file removal.
   */
  private void registerShutdownHook() {
    Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
      @Override
      public void run() {
        try {
          file.close();
        } catch (IOException e) {
          // No exception handling in a shutdown hook
        }
      }
    }));
  }
}
