// Copyright 2015 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.android.ziputils;

import com.google.common.base.Preconditions;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
 * An API for reading big files through a direct byte buffer spanning a region of the file.
 * This object maintains an internal buffer, which may store all or some of the file content.
 * When a request for data is made ({@link #getBuffer(long, int) }, the implementation will
 * first determine if the requested data range is within the region specified at time of
 * construction. If it is, it checks to see if the request is within the capacity range of
 * the current internal buffer. If not, the buffer is reallocated, based at the requested offset.
 * Then the implementation checks to see if the requested data falls within the current fill limit
 * of the internal buffer. If not additional data is read from the file. Finally, a slice of
 * the internal buffer is returned, with the requested data.
 *
 * <p>This is optimized for forward scanning of files. Random access is supported, but will likely
 * be inefficient, especially if the entire file doesn't fit in the internal buffer.
 *
 * <p>Clients of this API should take care not to keep references to returned buffers indefinitely,
 * as this would prevent collection of buffers discarded by the {@code BufferedFile} object.
 */
public class BufferedFile {

   private int maxAlloc;
   private long offset;
   private long limit;
   private FileChannel channel;
   private ByteBuffer current;
   private long currOff;

  /**
   * Same as {@code BufferedFile(channel, 0, channel.size(), blockSize)}.
   *
   * @param channel file channel opened for reading.
   * @param blockSize maximum buffer allocation.
   * @throws NullPointerException if {@code channel} is {@code null}.
   * @throws IllegalArgumentException if {@code maxAlloc}, {@code off}, or {@code len} are negative
   * or if {@code off + len > channel.size()}.
   * @throws IOException
   */
  public BufferedFile(FileChannel channel, int blockSize) throws IOException {
    this(channel, 0, channel.size(), blockSize);
  }

  /**
   * Allocates a buffered file.
   *
   * @param channel file channel opened for reading.
   * @param off the first byte that can be read through this object.
   * @param len the max number of bytes that can be read through this object.
   * @param blockSize default max buffer allocation size is {@code Math.min(blockSize, len)}.
   * @throws NullPointerException if {@code channel} is {@code null}.
   * @throws IllegalArgumentException if {@code blockSize}, {@code off}, or {@code len} are negative
   * or if {@code off + len > channel.size()}.
   * @throws IOException if thrown by the underlying file channel.
   */
  public BufferedFile(FileChannel channel, long off, long len, int blockSize) throws IOException {
    Preconditions.checkNotNull(channel);
    Preconditions.checkArgument(blockSize >= 0);
    Preconditions.checkArgument(off >= 0);
    Preconditions.checkArgument(len >= 0);
    Preconditions.checkArgument(off + len <= channel.size());
    this.maxAlloc = (int) Math.min(blockSize, len);
    this.offset = off;
    this.limit = off + len;
    this.channel = channel;
    this.current = null;
    currOff = -1;
  }

  /**
   * Returns the offset of the first byte beyond the readable region.
   * @return the file offset just beyond the readable region.
   */
  public long limit() {
    return limit;
  }

  /**
   * Returns a byte buffer for reading {@code len} bytes from the {@code off} position
   * in the file. If the requested bytes are already loaded in the internal buffer, a slice is
   * returned, with position 0 and limit set to {@code len}. The slice may have a capacity greater
   * than its limit, if more bytes are already available in the internal buffer. If the requested
   * bytes are not available, but can fit in the current internal buffer, then more data is read,
   * before a slice is created as described above. If the requested data falls outside the range
   * that can be fitted into the current internal buffer, then a new internal buffer is allocated.
   * The prior internal buffer (if any), is no longer referenced by this object (but it may still
   * be referenced by the client, holding references to byte buffers returned from prior call to
   * this method). The new internal buffer will be based at {@code off} file position, and have a
   * capacity equal to the maximum of the {@code blockSize} of this buffer and {@code len}, except
   * that it will never exceed the the number of bytes from  {@code off} to the end of the readable
   * region of the file (min-max rule).
   *
   * @param off
   * @param len
   * @return a slice of the internal byte buffer containing the requested data. Except, if the
   * client request data beyond the readable region of the file, the {@code len} value is reduced
   * to the maximum number of bytes available from the given {@code off}.
   * @throws IllegalArgumentException if {@code len} is less than 0, or {@code off} is outside the
   * readable region specified when constructing this object.
   * @throws IOException if thrown by the underlying file channel.
   */
  public synchronized ByteBuffer getBuffer(long off, int len) throws IOException {
    Preconditions.checkArgument(off >= offset);
    Preconditions.checkArgument(len >= 0);
    Preconditions.checkArgument(off < limit || (off == limit && len == 0));
    if (limit - off < len) { // never return data beyond limit
      len = (int) (limit - off);
    }
    Preconditions.checkState(off + len <= limit);
    if (current == null || off < currOff || off + len > currOff + current.capacity()) {
      allocate(off, len);
      Preconditions.checkState(current != null && off == currOff
          && off + len <= currOff + current.capacity());
    }
    Preconditions.checkState(current != null && off >= currOff
        && off + len <= currOff + current.capacity());
    if (off - currOff + len > current.limit()) {
      readMore((int) (off - currOff) + len);
    }
    Preconditions.checkState(current != null && off >= currOff
        && off + len <= currOff + current.limit());
    current.position((int) (off - currOff));
    return (ByteBuffer) current.slice().limit(len);
  }

  private void readMore(int newMin) throws IOException {
    channel.position(currOff + current.limit());
    current.position(current.limit());
    current.limit(current.capacity());
    do {
      channel.read(current);
    } while(current.position() < newMin);
    current.limit(current.position()).position(0);
  }

  private void allocate(long off, int len) {
    current = ByteBuffer.allocateDirect(bufferSize(off, len));
    current.limit(0);
    currOff = off;
  }

  private int bufferSize(long off, int len) {
    return (int) Math.min(Math.max(len, maxAlloc), limit - off);
  }
}
