/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.apache.beam.runners.dataflow.worker;

import static org.apache.beam.vendor.guava.v26_0_jre.com.google.common.base.Preconditions.checkArgument;
import static org.apache.beam.vendor.guava.v26_0_jre.com.google.common.base.Preconditions.checkNotNull;
import static org.apache.beam.vendor.guava.v26_0_jre.com.google.common.base.Preconditions.checkState;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.NonWritableChannelException;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.SeekableByteChannel;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.NavigableMap;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.SortedMap;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import org.apache.beam.runners.dataflow.internal.IsmFormat;
import org.apache.beam.runners.dataflow.internal.IsmFormat.Footer;
import org.apache.beam.runners.dataflow.internal.IsmFormat.FooterCoder;
import org.apache.beam.runners.dataflow.internal.IsmFormat.IsmRecord;
import org.apache.beam.runners.dataflow.internal.IsmFormat.IsmRecordCoder;
import org.apache.beam.runners.dataflow.internal.IsmFormat.IsmShard;
import org.apache.beam.runners.dataflow.internal.IsmFormat.KeyPrefix;
import org.apache.beam.runners.dataflow.internal.IsmFormat.KeyPrefixCoder;
import org.apache.beam.runners.dataflow.util.RandomAccessData;
import org.apache.beam.runners.dataflow.worker.util.ScalableBloomFilter;
import org.apache.beam.runners.dataflow.worker.util.ScalableBloomFilter.ScalableBloomFilterCoder;
import org.apache.beam.runners.dataflow.worker.util.ValueInEmptyWindows;
import org.apache.beam.runners.dataflow.worker.util.common.worker.NativeReader;
import org.apache.beam.sdk.coders.ByteArrayCoder;
import org.apache.beam.sdk.io.FileSystems;
import org.apache.beam.sdk.io.fs.ResourceId;
import org.apache.beam.sdk.util.VarInt;
import org.apache.beam.sdk.util.WeightedValue;
import org.apache.beam.sdk.util.WindowedValue;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.annotations.VisibleForTesting;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.base.MoreObjects;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.base.Optional;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.base.Preconditions;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.base.Throwables;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.cache.Cache;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.collect.ImmutableList;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.collect.ImmutableSortedMap;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.collect.Ordering;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.io.ByteStreams;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.primitives.Ints;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.primitives.Longs;

/**
 * A {@link NativeReader} that reads Ism files.
 *
 * @param <V> the type of the value written to the sink
 */
// Possible real inconsistency - https://issues.apache.org/jira/browse/BEAM-6560
@SuppressFBWarnings("IS2_INCONSISTENT_SYNC")
public class IsmReaderImpl<V> extends IsmReader<V> {
  /**
   * This constant represents the distance we would rather read and drop bytes for versus doing an
   * actual repositioning of the underlying stream. Tuned for operation within GCS.
   */
  private static final int SEEK_VS_READ = 6 * 1024 * 1024;

  static final int MAX_SHARD_INDEX_AND_FOOTER_SIZE = 1024 * 1024;

  private final ResourceId resourceId;
  private final IsmRecordCoder<V> coder;

  /** Lazily initialized on first read. */
  private long length;

  private Footer footer;

  /** A map indexed by shard id, storing the Ism shard descriptors. */
  private NavigableMap<Integer, IsmShard> shardIdToShardMap;

  /**
   * A map sorted and indexed by the block offset for a given shard. The sorting is important so the
   * {@link LazyIsmPrefixReaderIterator} can read through the file in increasing order.
   */
  private NavigableMap<Long, IsmShard> shardOffsetToShardMap;

  /** Values lazily initialized per shard on first keyed read of each shard. */
  private Map<Integer, ImmutableSortedMap<RandomAccessData, IsmShardKey>> indexPerShard;

  ScalableBloomFilter bloomFilter;

  /**
   * A cache instance which if set on this reader is used to cache blocks of data that are read.
   * Each value represents the decoded form of a block.
   */
  private final Cache<
          IsmShardKey, WeightedValue<NavigableMap<RandomAccessData, WindowedValue<IsmRecord<V>>>>>
      cache;

  /**
   * Produces a reader for the specified {@code resourceId} and {@code coder}. See {@link IsmFormat}
   * for encoded format details.
   */
  IsmReaderImpl(
      final ResourceId resourceId,
      IsmRecordCoder<V> coder,
      Cache<IsmShardKey, WeightedValue<NavigableMap<RandomAccessData, WindowedValue<IsmRecord<V>>>>>
          cache) {
    checkNotNull(cache);
    IsmFormat.validateCoderIsCompatible(coder);
    this.resourceId = resourceId;
    this.coder = coder;
    this.cache = cache;
  }

  @Override
  public IsmPrefixReaderIterator iterator() throws IOException {
    SideInputReadCounter readCounter = IsmReader.getCurrentSideInputCounter();
    return new LazyIsmPrefixReaderIterator(readCounter);
  }

  @Override
  public IsmPrefixReaderIterator overKeyComponents(List<?> keyComponents) throws IOException {
    if (keyComponents.isEmpty()) {
      return overKeyComponents(keyComponents, 0, new RandomAccessData(0));
    }
    RandomAccessData keyBytes = new RandomAccessData();
    int shardId = coder.encodeAndHash(keyComponents, keyBytes);
    return overKeyComponents(keyComponents, shardId, keyBytes);
  }

  @Override
  public IsmPrefixReaderIterator overKeyComponents(
      List<?> keyComponents, int shardId, RandomAccessData keyBytes) throws IOException {
    checkNotNull(keyComponents);
    checkNotNull(keyBytes);
    SideInputReadCounter readCounter = IsmReader.getCurrentSideInputCounter();
    if (keyComponents.isEmpty()) {
      checkArgument(
          shardId == 0 && keyBytes.size() == 0,
          "Expected shard id to be 0 and key bytes to be empty "
              + "but got shard id %s and key bytes of length %s",
          shardId,
          keyBytes.size());
    }
    checkArgument(
        keyComponents.size() <= coder.getKeyComponentCoders().size(),
        "Expected at most %s key component(s) but received %s.",
        coder.getKeyComponentCoders().size(),
        keyComponents);

    Optional<SeekableByteChannel> inChannel =
        initializeFooterAndShardIndex(Optional.<SeekableByteChannel>absent(), readCounter);

    // If this file is empty, we can return an empty iterator.
    if (footer.getNumberOfKeys() == 0) {
      return new EmptyIsmPrefixReaderIterator(keyComponents);
    }

    // If not enough key components to figure out which shard was requested, we return a reader
    // iterator over all the keys.
    if (keyComponents.size() < coder.getNumberOfShardKeyCoders(keyComponents)) {
      return new ShardAwareIsmPrefixReaderIterator(
          keyComponents, openIfNeeded(inChannel), readCounter);
    }

    // If this file does not contain the shard or Bloom filter does not contain the key prefix,
    // we know that we can return an empty reader iterator.
    if (!shardIdToShardMap.containsKey(shardId)) {
      return new EmptyIsmPrefixReaderIterator(keyComponents);
    }
    inChannel = initializeForKeyedRead(shardId, inChannel, readCounter);
    closeIfPresent(inChannel);
    if (!bloomFilterMightContain(keyBytes)) {
      return new EmptyIsmPrefixReaderIterator(keyComponents);
    }

    // Otherwise we may actually contain the key so construct a reader iterator
    // which will fetch the data blocks containing the requested key prefix.

    // We find the first key in the index which may contain our prefix
    RandomAccessData floorKey = indexPerShard.get(shardId).floorKey(keyBytes);

    // We compute an upper bound on the key prefix by incrementing the prefix
    RandomAccessData keyBytesUpperBound = keyBytes.increment();
    // Compute the sub-range of the index map that we want to iterate over since
    // any of these blocks may contain the key prefix.
    Iterator<IsmShardKey> blockEntries =
        indexPerShard.get(shardId).subMap(floorKey, keyBytesUpperBound).values().iterator();

    return new WithinShardIsmPrefixReaderIterator(
        keyComponents, keyBytes, keyBytesUpperBound, blockEntries, readCounter);
  }

  /** Returns whether this ISM reader has been initialized. */
  @Override
  public boolean isInitialized() {
    return footer != null;
  }

  @Override
  public boolean isEmpty() throws IOException {
    SideInputReadCounter readCounter = IsmReader.getCurrentSideInputCounter();
    closeIfPresent(
        initializeFooterAndShardIndex(Optional.<SeekableByteChannel>absent(), readCounter));
    return footer.getNumberOfKeys() == 0;
  }

  @Override
  public ResourceId getResourceId() {
    return resourceId;
  }

  @Override
  public IsmRecordCoder<V> getCoder() {
    return coder;
  }

  @Override
  public String toString() {
    return MoreObjects.toStringHelper(IsmReaderImpl.class)
        .add("resource id", resourceId)
        .add("coder", coder)
        .toString();
  }

  // Overridable by tests to get around the bloom filter not containing any values.
  @VisibleForTesting
  boolean bloomFilterMightContain(RandomAccessData keyBytes) {
    return bloomFilter.mightContain(keyBytes.array(), 0, keyBytes.size());
  }

  /**
   * Initialize this Ism reader by reading the footer and shard index. Returns a channel for re-use
   * if this method was required to open one.
   */
  private synchronized Optional<SeekableByteChannel> initializeFooterAndShardIndex(
      Optional<SeekableByteChannel> inChannel, SideInputReadCounter readCounter)
      throws IOException {
    if (footer != null) {
      checkState(
          shardIdToShardMap != null, "Expected shard id to shard map to have been initialized.");
      checkState(
          shardOffsetToShardMap != null,
          "Expected shard offset to shard map to have been initialized.");
      return inChannel;
    }
    checkState(
        shardIdToShardMap == null, "Expected shard id to shard map to not have been initialized.");
    checkState(
        shardOffsetToShardMap == null,
        "Expected shard offset to shard map to not have been initialized.");

    SeekableByteChannel rawChannel;
    RandomAccessData data;
    long startPosition;
    try (Closeable closeReadCounter = readCounter.enter()) {
      rawChannel = openIfNeeded(inChannel);
      this.length = rawChannel.size();

      // We read the last chunk of data, for small files we will capture the entire file.
      // We may capture the Bloom filter, shard index, and footer for slightly larger files.
      // Otherwise we are guaranteed to capture the footer and the shard index.
      startPosition = Math.max(length - MAX_SHARD_INDEX_AND_FOOTER_SIZE, 0);
      position(rawChannel, startPosition);
      data = new RandomAccessData(ByteStreams.toByteArray(Channels.newInputStream(rawChannel)));
    }
    readCounter.addBytesRead(data.size());
    // Read the fixed length footer.
    this.footer =
        FooterCoder.of()
            .decode(data.asInputStream(data.size() - Footer.FIXED_LENGTH, Footer.FIXED_LENGTH));

    checkState(
        startPosition < footer.getIndexPosition(),
        "Malformed file, expected to have been able to read entire shard index.");
    int offsetWithinReadData = (int) (footer.getIndexPosition() - startPosition);

    // Decode the list of Ism shard descriptors
    List<IsmShard> ismShards =
        IsmFormat.ISM_SHARD_INDEX_CODER.decode(
            data.asInputStream(offsetWithinReadData, data.size() - offsetWithinReadData));

    // Build the shard id to shard descriptor map
    ImmutableSortedMap.Builder<Integer, IsmShard> shardIdToShardMapBuilder =
        ImmutableSortedMap.orderedBy(Ordering.<Integer>natural());
    for (IsmShard ismShard : ismShards) {
      shardIdToShardMapBuilder.put(ismShard.getId(), ismShard);
    }
    shardIdToShardMap = shardIdToShardMapBuilder.build();

    // Build the shard block offset to shard descriptor map
    ImmutableSortedMap.Builder<Long, IsmShard> shardOffsetToShardMapBuilder =
        ImmutableSortedMap.orderedBy(Ordering.<Long>natural());
    for (IsmShard ismShard : ismShards) {
      shardOffsetToShardMapBuilder.put(ismShard.getBlockOffset(), ismShard);
    }
    shardOffsetToShardMap = shardOffsetToShardMapBuilder.build();

    // We may have gotten the Bloom filter, if so lets store it.
    if (startPosition < footer.getBloomFilterPosition()) {
      Optional<SeekableByteChannel> cachedDataChannel =
          Optional.<SeekableByteChannel>of(
              new CachedTailSeekableByteChannel(startPosition, data.array()));
      initializeBloomFilterAndIndexPerShard(cachedDataChannel);

      // For small files, we may have read the whole thing during initialization so
      // lets cache all this information. Note that this is important for the many small files
      // case since the IsmSideInputReader does initialization in parallel.
      if (cache != null && startPosition == 0) {
        for (IsmShard ismShard : ismShards) {
          initializeForKeyedRead(ismShard.getId(), cachedDataChannel, readCounter);
        }
        for (SortedMap<RandomAccessData, IsmShardKey> shards : indexPerShard.values()) {
          for (Map.Entry<RandomAccessData, IsmShardKey> block : shards.entrySet()) {
            cache.put(
                block.getValue(),
                new IsmCacheLoader(block.getValue()).call(cachedDataChannel.get()));
          }
        }
      }
    }

    return Optional.of(rawChannel);
  }

  /**
   * Initializes the Bloom filter and index per shard. We prepopulate empty indices for shards where
   * the index offset matches the following shard block offset. Re-uses the provided channel,
   * returning it or a new one if this method was required to open one.
   */
  private synchronized Optional<SeekableByteChannel> initializeBloomFilterAndIndexPerShard(
      Optional<SeekableByteChannel> inChannel) throws IOException {
    if (indexPerShard != null) {
      checkState(bloomFilter != null, "Expected Bloom filter to have been initialized.");
      return inChannel;
    }

    SeekableByteChannel rawChannel = openIfNeeded(inChannel);

    // Set the position to where the bloom filter is and read it in.
    position(rawChannel, footer.getBloomFilterPosition());
    bloomFilter = ScalableBloomFilterCoder.of().decode(Channels.newInputStream(rawChannel));

    indexPerShard = new HashMap<>();
    // If a shard is small, it may not contain an index and we can detect this and
    // prepopulate the shard index map with an empty entry if the start of the index
    // and start of the next block are equal
    Iterator<IsmShard> shardIterator = shardOffsetToShardMap.values().iterator();

    // If file is empty we just return here.
    if (!shardIterator.hasNext()) {
      return Optional.of(rawChannel);
    }

    // If the current shard's index position is equal to the next shards block offset
    // then we know that the index contains no data and we can pre-populate it with
    // the empty map.
    IsmShard currentShard = shardIterator.next();
    while (shardIterator.hasNext()) {
      IsmShard nextShard = shardIterator.next();
      if (currentShard.getIndexOffset() == nextShard.getBlockOffset()) {
        indexPerShard.put(
            currentShard.getId(),
            ImmutableSortedMap.<RandomAccessData, IsmShardKey>orderedBy(
                    RandomAccessData.UNSIGNED_LEXICOGRAPHICAL_COMPARATOR)
                .put(
                    new RandomAccessData(0),
                    new IsmShardKey(
                        IsmReaderImpl.this.resourceId.toString(),
                        new RandomAccessData(0),
                        currentShard.getBlockOffset(),
                        currentShard.getIndexOffset()))
                .build());
      }
      currentShard = nextShard;
    }

    // Add an entry for the last shard if its index offset is equivalent to the
    // start of the Bloom filter, then we know that the index is empty.
    if (currentShard.getIndexOffset() == footer.getBloomFilterPosition()) {
      indexPerShard.put(
          currentShard.getId(),
          ImmutableSortedMap.<RandomAccessData, IsmShardKey>orderedBy(
                  RandomAccessData.UNSIGNED_LEXICOGRAPHICAL_COMPARATOR)
              .put(
                  new RandomAccessData(0),
                  new IsmShardKey(
                      IsmReaderImpl.this.resourceId.toString(),
                      new RandomAccessData(0),
                      currentShard.getBlockOffset(),
                      currentShard.getIndexOffset()))
              .build());
    }

    return Optional.of(rawChannel);
  }

  /** A unique key used to fully describe an Ism shard. */
  static final class IsmShardKey {
    private final String resourceId;
    private final RandomAccessData firstKey;
    private final long startOffset;
    private final long endOffset;

    IsmShardKey(String resourceId, RandomAccessData firstKey, long startOffset, long endOffset) {
      this.resourceId = resourceId;
      this.firstKey = firstKey;
      this.startOffset = startOffset;
      this.endOffset = endOffset;
    }

    @Override
    public String toString() {
      return MoreObjects.toStringHelper(IsmShardKey.class)
          .add("resource id", resourceId)
          .add("firstKey", firstKey)
          .add("startOffset", startOffset)
          .add("endOffset", endOffset)
          .toString();
    }

    @Override
    public boolean equals(Object obj) {
      if (!(obj instanceof IsmShardKey)) {
        return false;
      }
      IsmShardKey key = (IsmShardKey) obj;
      return startOffset == key.startOffset
          && endOffset == key.endOffset
          && Objects.equals(resourceId, key.resourceId)
          && Objects.equals(firstKey, key.firstKey);
    }

    @Override
    public int hashCode() {
      int result = resourceId.hashCode();
      result = result * 31 + firstKey.hashCode();
      result = result * 31 + Longs.hashCode(startOffset);
      result = result * 31 + Longs.hashCode(endOffset);
      return result;
    }
  }

  /**
   * Initializes the footer, shard index, Bloom filter and index for the requested shard id if they
   * have not been initialized yet. Re-uses the provided channel, returning it or a new one if this
   * method was required to open one.
   */
  // Real bug - https://issues.apache.org/jira/browse/BEAM-6559
  @SuppressFBWarnings("NP_NULL_ON_SOME_PATH")
  private Optional<SeekableByteChannel> initializeForKeyedRead(
      int shardId, Optional<SeekableByteChannel> inChannel, SideInputReadCounter readCounter)
      throws IOException {
    inChannel = initializeFooterAndShardIndex(inChannel, readCounter);

    IsmShard shardWithIndex = shardIdToShardMap.get(shardId);

    // If this shard id is not within this file, we can return immediately.
    if (shardWithIndex == null) {
      return inChannel;
    }

    inChannel = initializeBloomFilterAndIndexPerShard(inChannel);

    // If the index has been populated and contains the shard id, we can return.
    if (indexPerShard != null && indexPerShard.containsKey(shardId)) {
      checkState(bloomFilter != null, "Bloom filter expected to have been initialized.");
      return inChannel;
    }

    checkState(
        indexPerShard.get(shardId) == null,
        "Expected to not have initialized index for shard %s",
        shardId);

    Long startOfNextBlock = shardOffsetToShardMap.higherKey(shardWithIndex.getBlockOffset());
    // If this is the last block, then we need to grab the position of the Bloom filter
    // as the upper bound.
    if (startOfNextBlock == null) {
      startOfNextBlock = footer.getBloomFilterPosition();
    }

    // Open the channel if needed and seek to the start of the index.
    SeekableByteChannel rawChannel = openIfNeeded(inChannel);
    rawChannel.position(shardWithIndex.getIndexOffset());
    InputStream inStream = Channels.newInputStream(rawChannel);

    ImmutableSortedMap.Builder<RandomAccessData, IsmShardKey> builder =
        ImmutableSortedMap.orderedBy(RandomAccessData.UNSIGNED_LEXICOGRAPHICAL_COMPARATOR);

    // Read the first key
    RandomAccessData currentKeyBytes = new RandomAccessData();
    readKey(inStream, currentKeyBytes);
    long currentOffset = VarInt.decodeLong(inStream);

    // Insert the entry that happens at the beginning limiting the shard block by the
    // first keys block offset.
    builder.put(
        new RandomAccessData(0),
        new IsmShardKey(
            IsmReaderImpl.this.resourceId.toString(),
            new RandomAccessData(0),
            shardWithIndex.getBlockOffset(),
            currentOffset));

    // While another index entry exists, insert an index entry with the key, and offsets
    // that limit the range of the shard block.
    while (rawChannel.position() < startOfNextBlock) {
      RandomAccessData nextKeyBytes = currentKeyBytes.copy();
      readKey(inStream, nextKeyBytes);
      long nextOffset = VarInt.decodeLong(inStream);

      builder.put(
          currentKeyBytes,
          new IsmShardKey(
              IsmReaderImpl.this.resourceId.toString(),
              currentKeyBytes,
              currentOffset,
              nextOffset));

      currentKeyBytes = nextKeyBytes;
      currentOffset = nextOffset;
    }

    // Upper bound the last entry with the index offset.
    builder.put(
        currentKeyBytes,
        new IsmShardKey(
            IsmReaderImpl.this.resourceId.toString(),
            currentKeyBytes,
            currentOffset,
            shardWithIndex.getIndexOffset()));
    indexPerShard.put(shardId, builder.build());

    return Optional.of(rawChannel);
  }

  /** A function which takes an IsmShardKey fully describing a data block to read and return. */
  private class IsmCacheLoader
      implements Callable<
          WeightedValue<NavigableMap<RandomAccessData, WindowedValue<IsmRecord<V>>>>> {

    private final IsmShardKey key;

    private IsmCacheLoader(IsmShardKey key) {
      this.key = key;
    }

    @Override
    public WeightedValue<NavigableMap<RandomAccessData, WindowedValue<IsmRecord<V>>>> call()
        throws IOException {
      // Open a channel and build a sorted map from key to value for each key value
      // pair found within the data block.
      try (SeekableByteChannel rawChannel = open()) {
        return call(rawChannel);
      }
    }

    public WeightedValue<NavigableMap<RandomAccessData, WindowedValue<IsmRecord<V>>>> call(
        SeekableByteChannel rawChannel) throws IOException {
      SideInputReadCounter readCounter = IsmReader.getCurrentSideInputCounter();
      try (WithinShardIsmReaderIterator readerIterator =
          new WithinShardIsmReaderIterator(
              rawChannel, key.firstKey, key.startOffset, key.endOffset, readCounter)) {

        ImmutableSortedMap.Builder<RandomAccessData, WindowedValue<IsmRecord<V>>> mapBuilder =
            ImmutableSortedMap.orderedBy(RandomAccessData.UNSIGNED_LEXICOGRAPHICAL_COMPARATOR);
        for (boolean more = readerIterator.start(); more; more = readerIterator.advance()) {
          RandomAccessData nextKey = readerIterator.getCurrentKeyBytes().copy();
          WindowedValue<IsmRecord<V>> next = readerIterator.getCurrent();
          mapBuilder.put(nextKey, next);
        }
        // We return the size of the data block as the weight of this data block.
        return WeightedValue.of(
            (NavigableMap<RandomAccessData, WindowedValue<IsmRecord<V>>>) mapBuilder.build(),
            key.endOffset - key.startOffset);
      }
    }
  }

  /**
   * Fetches the data block requested.
   *
   * <p>If the cache is available, we will load and cache the requested block. Otherwise, we will
   * load and return the block.
   */
  private NavigableMap<RandomAccessData, WindowedValue<IsmRecord<V>>> fetch(IsmShardKey key)
      throws IOException {
    try {
      if (cache == null) {
        return new IsmCacheLoader(key).call().getValue();
      } else {
        return cache.get(key, new IsmCacheLoader(key)).getValue();
      }
    } catch (ExecutionException e) {
      // Try and re-throw the root cause if its an IOException
      Throwables.propagateIfPossible(e.getCause(), IOException.class);
      throw new IOException(e.getCause());
    }
  }

  /** The base class of Ism reader iterators which operate over a given key prefix. */
  abstract class IsmPrefixReaderIteratorImpl extends IsmPrefixReaderIterator {
    private final List<?> keyComponents;
    final SideInputReadCounter readCounter;

    private IsmPrefixReaderIteratorImpl(List<?> keyComponents, SideInputReadCounter readCounter) {
      this.keyComponents = keyComponents;
      this.readCounter = readCounter;
    }

    /** Returns the list of key components representing this iterators key prefix. */
    protected List<?> getKeyComponents() {
      return keyComponents;
    }

    /**
     * Concatenates this reader iterators key components with the additionally supplied key
     * components and encodes them into their byte representations producing a key. Returns the
     * exact record represented by the key generated above.
     *
     * <p>Null is returned if no key has all the key components as a prefix within this file.
     */
    @Override
    public final WindowedValue<IsmRecord<V>> get(List<?> additionalKeyComponents)
        throws IOException {
      RandomAccessData keyBytes = new RandomAccessData();
      try (Closeable readerCloser = readCounter.enter()) {
        int shardId =
            coder.encodeAndHash(
                ImmutableList.builder()
                    .addAll(keyComponents)
                    .addAll(additionalKeyComponents)
                    .build(),
                keyBytes);
        return getBlock(keyBytes, shardId, readCounter).get(keyBytes);
      }
    }

    /**
     * Returns the record for the last key having this iterators key prefix. Last is defined as the
     * largest key with the same key prefix when comparing key's byte representations using an
     * unsigned lexicographical byte order.
     *
     * <p>Null is returned if the prefix is not present within this file.
     */
    @Override
    public WindowedValue<IsmRecord<V>> getLast() throws IOException {
      RandomAccessData keyBytes = new RandomAccessData();
      int shardId = coder.encodeAndHash(keyComponents, keyBytes);

      Optional<SeekableByteChannel> inChannel =
          initializeFooterAndShardIndex(Optional.<SeekableByteChannel>absent(), readCounter);

      // Key is not stored here
      if (!shardIdToShardMap.containsKey(shardId) || !bloomFilterMightContain(keyBytes)) {
        return null;
      }

      inChannel = initializeForKeyedRead(shardId, inChannel, readCounter);
      closeIfPresent(inChannel);

      final NavigableMap<RandomAccessData, IsmShardKey> indexInShard = indexPerShard.get(shardId);
      RandomAccessData end = keyBytes.increment();
      final IsmShardKey cacheEntry = indexInShard.floorEntry(end).getValue();

      NavigableMap<RandomAccessData, WindowedValue<IsmRecord<V>>> block;
      try (Closeable readerCloser = IsmReader.setSideInputReadContext(readCounter)) {
        block = fetch(cacheEntry);
      }

      RandomAccessData lastKey = block.lastKey();

      // If the requested key is greater than the last key within the block, then it
      // does not exist.
      if (RandomAccessData.UNSIGNED_LEXICOGRAPHICAL_COMPARATOR.compare(keyBytes, lastKey) > 0) {
        return null;
      }

      Entry<RandomAccessData, WindowedValue<IsmRecord<V>>> rval = block.floorEntry(end);

      // If the prefix matches completely then we can return
      if (RandomAccessData.UNSIGNED_LEXICOGRAPHICAL_COMPARATOR.commonPrefixLength(
              keyBytes, rval.getKey())
          == keyBytes.size()) {
        return rval.getValue();
      }
      return null;
    }
  }

  /** An empty reader iterator. */
  class EmptyIsmPrefixReaderIterator extends IsmPrefixReaderIteratorImpl {
    private EmptyIsmPrefixReaderIterator(List<?> keyComponents) {
      super(keyComponents, NoopSideInputReadCounter.INSTANCE);
    }

    @Override
    public boolean start() throws IOException {
      return false;
    }

    @Override
    public boolean advance() throws IOException {
      return false;
    }

    @Override
    public WindowedValue<IsmRecord<V>> getCurrent() throws NoSuchElementException {
      throw new NoSuchElementException();
    }
  }

  /** A reader iterator which initializes its input stream lazily. */
  class LazyIsmPrefixReaderIterator extends IsmPrefixReaderIteratorImpl {
    private IsmPrefixReaderIterator delegate;

    public LazyIsmPrefixReaderIterator(SideInputReadCounter readCounter) {
      super(ImmutableList.of(), readCounter);
    }

    @Override
    public boolean start() throws IOException {
      checkState(delegate == null, "Already started");
      try (Closeable counterCloser = IsmReader.setSideInputReadContext(readCounter)) {
        delegate = overKeyComponents(getKeyComponents());
      }
      return delegate.start();
    }

    @Override
    public boolean advance() throws IOException {
      checkState(delegate != null, "Not started");
      return delegate.advance();
    }

    @Override
    public WindowedValue<IsmRecord<V>> getCurrent() {
      checkState(delegate != null, "Not started");
      return delegate.getCurrent();
    }

    @Override
    public void close() throws IOException {
      if (delegate != null) {
        delegate.close();
      }
    }
  }

  /**
   * Returns a map from key to value, where the keys are in increasing lexicographical order. If the
   * requested key is not contained within this file, an empty map is returned.
   */
  private NavigableMap<RandomAccessData, WindowedValue<IsmRecord<V>>> getBlock(
      RandomAccessData keyBytes, int shardId, SideInputReadCounter readCounter) throws IOException {
    Optional<SeekableByteChannel> inChannel =
        initializeFooterAndShardIndex(Optional.<SeekableByteChannel>absent(), readCounter);

    // Key is not stored here so return an empty map.
    if (!shardIdToShardMap.containsKey(shardId) || !bloomFilterMightContain(keyBytes)) {
      return ImmutableSortedMap.<RandomAccessData, WindowedValue<IsmRecord<V>>>orderedBy(
              RandomAccessData.UNSIGNED_LEXICOGRAPHICAL_COMPARATOR)
          .build();
    }

    inChannel = initializeForKeyedRead(shardId, inChannel, readCounter);
    closeIfPresent(inChannel);

    final NavigableMap<RandomAccessData, IsmShardKey> indexInShard = indexPerShard.get(shardId);
    final IsmShardKey cacheEntry = indexInShard.floorEntry(keyBytes).getValue();
    try (Closeable readerCloseable = IsmReader.setSideInputReadContext(readCounter)) {
      return fetch(cacheEntry);
    }
  }

  /**
   * A reader iterator that returns all elements from prefix (inclusive) to prefixUpperBound
   * (exclusive) within the set of block entries provided.
   */
  private class WithinShardIsmPrefixReaderIterator extends IsmPrefixReaderIteratorImpl {
    private final Iterator<IsmShardKey> blockEntriesIterator;
    Iterator<WindowedValue<IsmRecord<V>>> iterator;
    private final RandomAccessData prefix;
    private final RandomAccessData prefixUpperBound;
    private Optional<WindowedValue<IsmRecord<V>>> current;

    private WithinShardIsmPrefixReaderIterator(
        List<?> keyComponents,
        RandomAccessData prefix,
        RandomAccessData prefixUpperBound,
        Iterator<IsmShardKey> blockEntriesIterator,
        SideInputReadCounter readCounter) {
      super(keyComponents, readCounter);
      checkNotNull(blockEntriesIterator);
      this.prefix = prefix;
      this.prefixUpperBound = prefixUpperBound;
      this.blockEntriesIterator = blockEntriesIterator;
    }

    @Override
    public boolean start() throws IOException {
      return advance();
    }

    @Override
    public boolean advance() throws IOException {
      // This is in a while loop because the blocks that we are asked to look into may
      // not contain the key prefix.
      while (iterator == null || !iterator.hasNext()) {
        // If there are no blocks to iterate over we can return false
        if (!blockEntriesIterator.hasNext()) {
          return false;
        }

        NavigableMap<RandomAccessData, WindowedValue<IsmRecord<V>>> map;
        try (Closeable counterCloseable = IsmReader.setSideInputReadContext(readCounter)) {
          IsmShardKey nextBlock = blockEntriesIterator.next();
          map = fetch(nextBlock);
        }
        SortedMap<RandomAccessData, WindowedValue<IsmRecord<V>>> submap =
            map.subMap(prefix, prefixUpperBound);
        Collection<WindowedValue<IsmRecord<V>>> values = submap.values();
        iterator = values.iterator();
      }
      current = Optional.of(iterator.next());
      return true;
    }

    @Override
    public WindowedValue<IsmRecord<V>> getCurrent() throws NoSuchElementException {
      if (!current.isPresent()) {
        throw new NoSuchElementException();
      }
      return current.get();
    }
  }

  /** A reader iterator that returns all records across all shards contained within this file. */
  private class ShardAwareIsmPrefixReaderIterator extends IsmPrefixReaderIteratorImpl {
    private final SeekableByteChannel rawChannel;
    private WithinShardIsmReaderIterator delegate;
    private Iterator<IsmShard> shardEntries;

    private ShardAwareIsmPrefixReaderIterator(
        List<?> keyComponents, SeekableByteChannel rawChannel, SideInputReadCounter readCounter)
        throws IOException {
      super(keyComponents, readCounter);
      checkState(
          shardOffsetToShardMap.size() > 0,
          "Expected that shard offset to shard map has been initialized and is not empty.");

      this.rawChannel = rawChannel;
      this.shardEntries = shardOffsetToShardMap.values().iterator();
      IsmShard firstShard = shardEntries.next();
      delegate =
          new WithinShardIsmReaderIterator(
              rawChannel,
              new RandomAccessData(),
              firstShard.getBlockOffset(),
              firstShard.getIndexOffset(),
              readCounter);
    }

    @Override
    public boolean start() throws IOException {
      checkState(delegate.start(), "Expected each shard to contain at least one entry");
      return true;
    }

    @Override
    public boolean advance() throws IOException {
      if (delegate.advance()) {
        return true;
      }
      // If our current shard index is empty, we need to move to the next one.
      if (!shardEntries.hasNext()) {
        return false;
      }
      IsmShard nextIsmShard = shardEntries.next();
      checkState(
          nextIsmShard.getBlockOffset() >= rawChannel.position(),
          "Expected channel read order to be sequential.");

      delegate =
          new WithinShardIsmReaderIterator(
              rawChannel,
              new RandomAccessData(),
              nextIsmShard.getBlockOffset(),
              nextIsmShard.getIndexOffset(),
              readCounter);

      checkState(delegate.start(), "Expected each shard to contain at least one entry.");
      return true;
    }

    @Override
    public WindowedValue<IsmRecord<V>> getCurrent() throws NoSuchElementException {
      return delegate.getCurrent();
    }

    @Override
    public void close() throws IOException {
      rawChannel.close();
    }

    @Override
    public WindowedValue<IsmRecord<V>> getLast() throws IOException {
      // Since this is an iterator over all of the file, we return the last record within the file.
      try (Closeable readerCloser = readCounter.enter()) {
        int lastShardId = shardOffsetToShardMap.lastEntry().getValue().getId();
        initializeForKeyedRead(lastShardId, Optional.<SeekableByteChannel>absent(), readCounter);
        NavigableMap<RandomAccessData, WindowedValue<IsmRecord<V>>> lastBlock =
            getBlock(indexPerShard.get(lastShardId).lastKey(), lastShardId, readCounter);
        return lastBlock.lastEntry().getValue();
      }
    }
  }

  /**
   * A reader iterator for Ism formatted files which returns a sequence of Ism records from the
   * underlying channel from where it is currently positioned till the supplied limit.
   *
   * <p>This reader iterator will use the supplied bytes as the bytes for the previous key which is
   * required to do the KeyPrefix based key decoding.
   */
  private class WithinShardIsmReaderIterator
      extends NativeReaderIterator<WindowedValue<IsmRecord<V>>> {
    private final SeekableByteChannel rawChannel;
    private final InputStream inStream;
    private final SideInputReadCounter readCounter;
    private long readLimit;
    private RandomAccessData keyBytes;
    private long position;
    private Optional<WindowedValue<IsmRecord<V>>> current;

    private WithinShardIsmReaderIterator(
        SeekableByteChannel rawChannel,
        RandomAccessData currentKeyBytes,
        long newPosition,
        long newLimit,
        SideInputReadCounter readCounter)
        throws IOException {
      checkNotNull(rawChannel);
      checkNotNull(currentKeyBytes);
      checkArgument(newLimit >= 0L);
      checkArgument(newPosition <= newLimit);
      this.rawChannel = rawChannel;
      this.inStream = Channels.newInputStream(rawChannel);
      this.keyBytes = currentKeyBytes.copy();
      this.position = newPosition;
      this.readLimit = newLimit;
      this.readCounter = readCounter;
      IsmReaderImpl.position(rawChannel, newPosition);
    }

    @Override
    public boolean start() throws IOException {
      return advance();
    }

    @Override
    public boolean advance() throws IOException, NoSuchElementException {
      checkState(position <= readLimit, "Read past end of stream");
      if (position == readLimit) {
        current = Optional.absent();
        return false;
      }

      final IsmRecord<V> ismRecord;
      readKey(inStream, keyBytes);
      InputStream keyInputStream = keyBytes.asInputStream(0, keyBytes.size());
      List<Object> keyComponents = new ArrayList<>(coder.getKeyComponentCoders().size());
      for (int i = 0; i < coder.getKeyComponentCoders().size(); ++i) {
        keyComponents.add(coder.getKeyComponentCoder(i).decode(keyInputStream));
      }

      if (IsmFormat.isMetadataKey(keyComponents)) {
        byte[] metadata = ByteArrayCoder.of().decode(inStream);
        ismRecord = IsmRecord.<V>meta(keyComponents, metadata);
      } else {
        V value = coder.getValueCoder().decode(inStream);
        ismRecord = IsmRecord.<V>of(keyComponents, value);
      }

      long newPosition = rawChannel.position();
      IsmReaderImpl.this.notifyElementRead(newPosition - position);
      readCounter.addBytesRead(newPosition - position);
      position = newPosition;
      current = Optional.of(new ValueInEmptyWindows<>(ismRecord));
      return true;
    }

    @Override
    public WindowedValue<IsmRecord<V>> getCurrent() {
      if (!current.isPresent()) {
        throw new NoSuchElementException();
      }
      return current.get();
    }

    public RandomAccessData getCurrentKeyBytes() {
      if (!current.isPresent()) {
        throw new NoSuchElementException();
      }
      return keyBytes;
    }
  }

  /**
   * Decodes a KeyPrefix from the stream and then reads unshared key bytes from the stream placing
   * them into the supplied keyBytes at position keyBytes[shared key bytes : shared key bytes +
   * unshared key bytes].
   */
  private static void readKey(InputStream inStream, RandomAccessData keyBytes) throws IOException {
    KeyPrefix keyPrefix = KeyPrefixCoder.of().decode(inStream);
    // currentKey = prevKey[0 : sharedKeySize] + read(unsharedKeySize)
    keyBytes.readFrom(
        inStream,
        keyPrefix.getSharedKeySize() /* start to overwrite the previous key at sharedKeySize */,
        keyPrefix.getUnsharedKeySize() /* read unsharedKeySize bytes from the stream */);
    // Reset the length incase the next key was shorter.
    keyBytes.resetTo(keyPrefix.getSharedKeySize() + keyPrefix.getUnsharedKeySize());
  }

  /** Closes the underlying channel if present. */
  private void closeIfPresent(Optional<SeekableByteChannel> inChannel) throws IOException {
    if (inChannel.isPresent()) {
      inChannel.get().close();
    }
  }

  /** Returns a channel, opening a new one if required. */
  private SeekableByteChannel openIfNeeded(Optional<SeekableByteChannel> inChannel)
      throws IOException {
    if (inChannel.isPresent()) {
      return inChannel.get();
    }
    return open();
  }

  /** Opens a new channel. */
  private SeekableByteChannel open() throws IOException {
    ReadableByteChannel channel = FileSystems.open(resourceId);
    Preconditions.checkArgument(
        channel instanceof SeekableByteChannel,
        "IsmReaderImpl requires a SeekableByteChannel for path %s but received %s.",
        resourceId,
        channel);
    return (SeekableByteChannel) channel;
  }

  /**
   * Seeks into the channel intelligently by either resetting the position or reading and discarding
   * bytes.
   */
  private static void position(SeekableByteChannel inChannel, long newPosition) throws IOException {
    long currentPosition = inChannel.position();
    // If just doing a read is cheaper discarding the bytes lets just do the read
    if (currentPosition < newPosition && newPosition - currentPosition <= SEEK_VS_READ) {
      ByteStreams.skipFully(Channels.newInputStream(inChannel), newPosition - currentPosition);
    } else {
      // Otherwise we will perform a seek
      inChannel.position(newPosition);
    }
  }

  /**
   * A {@link SeekableByteChannel} which uses a cached data segment representing the tail of a
   * {@link ReadableByteChannel}. Note that this channel only supports read operations. Closing this
   * channel is a no-op.
   */
  static class CachedTailSeekableByteChannel implements SeekableByteChannel {
    final long offset;
    final byte[] data;
    // Represents the relative position inside of data
    int localPosition;

    CachedTailSeekableByteChannel(long offset, byte[] data) {
      checkArgument(offset >= 0, "Offset must be non-negative.");
      checkNotNull(data, "Cached data must not be null.");
      this.offset = offset;
      this.data = data;
    }

    @Override
    public boolean isOpen() {
      return true;
    }

    @Override
    public void close() throws IOException {}

    @Override
    public int read(ByteBuffer dst) throws IOException {
      if (localPosition >= data.length) {
        return -1;
      }
      int length = Math.min(dst.remaining(), data.length - localPosition);
      dst.put(data, localPosition, length);
      localPosition += length;
      return length;
    }

    @Override
    public int write(ByteBuffer src) throws IOException {
      throw new NonWritableChannelException();
    }

    @Override
    public long position() throws IOException {
      // The external position is our relative position inside of data plus the offset.
      return offset + localPosition;
    }

    @Override
    public SeekableByteChannel position(long newPosition) throws IOException {
      checkArgument(
          newPosition >= offset && newPosition <= offset + data.length,
          "Cannot seek to position %s which is outside of cached data range [%s, %s].",
          newPosition,
          offset,
          offset + data.length);
      localPosition = Ints.checkedCast(newPosition - offset);
      return this;
    }

    @Override
    public long size() throws IOException {
      // The external size is the offset plus the amount of data that we are caching.
      return offset + data.length;
    }

    @Override
    public SeekableByteChannel truncate(long size) throws IOException {
      throw new NonWritableChannelException();
    }
  }
}
