/*
 * Jooby https://jooby.io
 * Apache License Version 2.0 https://jooby.io/LICENSE.txt
 * Copyright 2014 Edgar Espina
 */
package io.jooby.output;

import java.nio.ByteBuffer;
import java.util.Iterator;
import java.util.List;

import edu.umd.cs.findbugs.annotations.NonNull;
import io.jooby.Context;
import io.jooby.SneakyThrows;

/**
 * Default implementation of {@link BufferedOutput}.
 *
 * @author edgar
 * @since 4.0.0
 */
public class ByteBufferedOutput implements BufferedOutput {
  private static final int MAX_CAPACITY = Integer.MAX_VALUE;

  private static final int CAPACITY_THRESHOLD = 1024 * 1024 * 4;

  private ByteBuffer buffer;

  private int readPosition;

  private int writePosition;

  /**
   * Creates a new byte buffered output.
   *
   * @param direct True for direct buffer.
   * @param capacity Buffer capacity.
   */
  public ByteBufferedOutput(boolean direct, int capacity) {
    this.buffer = allocate(capacity, direct);
  }

  @Override
  public int size() {
    return this.writePosition - this.readPosition;
  }

  @Override
  public void transferTo(@NonNull SneakyThrows.Consumer<ByteBuffer> consumer) {
    consumer.accept(asByteBuffer());
  }

  @Override
  public Iterator<ByteBuffer> iterator() {
    return List.of(asByteBuffer()).iterator();
  }

  @Override
  public ByteBuffer asByteBuffer() {
    return this.buffer.slice(this.readPosition, size()).asReadOnlyBuffer();
  }

  @Override
  public BufferedOutput write(byte b) {
    ensureWritable(1);
    this.buffer.put(this.writePosition, b);
    this.writePosition += 1;
    return this;
  }

  @Override
  public BufferedOutput write(byte[] source) {
    return write(source, 0, source.length);
  }

  @Override
  public BufferedOutput write(byte[] source, int offset, int length) {
    ensureWritable(length);

    var tmp = this.buffer.duplicate();
    int limit = this.writePosition + length;
    tmp.clear().position(this.writePosition).limit(limit);
    tmp.put(source, offset, length);

    this.writePosition += length;
    return this;
  }

  @Override
  public BufferedOutput write(@NonNull ByteBuffer source) {
    ensureWritable(source.remaining());
    var length = source.remaining();
    var tmp = this.buffer.duplicate();
    var limit = this.writePosition + source.remaining();
    tmp.clear().position(this.writePosition).limit(limit);
    tmp.put(source);
    this.writePosition += length;
    return this;
  }

  @Override
  public BufferedOutput clear() {
    this.writePosition = 0;
    this.readPosition = 0;
    this.buffer.clear();
    return this;
  }

  @Override
  public void send(Context ctx) {
    ctx.send(this.buffer.slice(this.readPosition, size()));
  }

  @Override
  public String toString() {
    return "readPosition="
        + this.readPosition
        + ", writePosition="
        + this.writePosition
        + ", size="
        + this.size()
        + ", capacity="
        + this.buffer.capacity();
  }

  private int writableByteCount() {
    return this.buffer.capacity() - this.writePosition;
  }

  /** Calculate the capacity of the buffer. */
  private int calculateCapacity(int neededCapacity) {
    if (neededCapacity == CAPACITY_THRESHOLD) {
      return CAPACITY_THRESHOLD;
    } else if (neededCapacity > CAPACITY_THRESHOLD) {
      int newCapacity = neededCapacity / CAPACITY_THRESHOLD * CAPACITY_THRESHOLD;
      if (newCapacity > MAX_CAPACITY - CAPACITY_THRESHOLD) {
        newCapacity = MAX_CAPACITY;
      } else {
        newCapacity += CAPACITY_THRESHOLD;
      }
      return newCapacity;
    } else {
      int newCapacity = 64;
      while (newCapacity < neededCapacity) {
        newCapacity <<= 1;
      }
      return newCapacity;
    }
  }

  private void setCapacity(int newCapacity) {
    if (newCapacity < 0) {
      throw new IllegalArgumentException(
          String.format("'newCapacity' %d must be 0 or higher", newCapacity));
    }
    var readPosition = this.readPosition;
    var writePosition = this.writePosition;
    var oldCapacity = this.buffer.capacity();

    if (newCapacity > oldCapacity) {
      var oldBuffer = this.buffer;
      var newBuffer = allocate(newCapacity, oldBuffer.isDirect());
      oldBuffer.position(0).limit(oldBuffer.capacity());
      newBuffer.position(0).limit(oldBuffer.capacity());
      newBuffer.put(oldBuffer);
      newBuffer.clear();
      this.buffer = newBuffer;
    } else if (newCapacity < oldCapacity) {
      var oldBuffer = this.buffer;
      var newBuffer = allocate(newCapacity, oldBuffer.isDirect());
      if (readPosition < newCapacity) {
        if (writePosition > newCapacity) {
          writePosition = newCapacity;
          this.writePosition = writePosition;
        }
        oldBuffer.position(readPosition).limit(writePosition);
        newBuffer.position(readPosition).limit(writePosition);
        newBuffer.put(oldBuffer);
        newBuffer.clear();
      } else {
        this.readPosition = newCapacity;
        this.writePosition = newCapacity;
      }
      this.buffer = newBuffer;
    }
  }

  private void ensureWritable(int length) {
    if (length > writableByteCount()) {
      int newCapacity = calculateCapacity(this.writePosition + length);
      setCapacity(newCapacity);
    }
  }

  private static ByteBuffer allocate(int capacity, boolean direct) {
    return direct ? ByteBuffer.allocateDirect(capacity) : ByteBuffer.allocate(capacity);
  }
}
