/*
 * Copyright 2016 Google Inc. 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 pink.madis.apk.arsc;

import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMap.Builder;
import com.google.common.io.LittleEndianDataOutputStream;
import com.google.common.primitives.Shorts;

import java.io.ByteArrayOutputStream;
import java.io.DataOutput;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Map;

import javax.annotation.Nullable;

/** Represents a generic chunk. */
public abstract class Chunk implements SerializableResource {

  /** Types of chunks that can exist. */
  public enum Type {
    NULL(0x0000),
    STRING_POOL(0x0001),
    TABLE(0x0002),
    XML(0x0003),
    XML_START_NAMESPACE(0x0100),
    XML_END_NAMESPACE(0x0101),
    XML_START_ELEMENT(0x0102),
    XML_END_ELEMENT(0x0103),
    XML_CDATA(0x0104),
    XML_RESOURCE_MAP(0x0180),
    TABLE_PACKAGE(0x0200),
    TABLE_TYPE(0x0201),
    TABLE_TYPE_SPEC(0x0202),
    TABLE_LIBRARY(0x0203);

    private final short code;

    private static final Map<Short, Type> FROM_SHORT;

    static {
      Builder<Short, Type> builder = ImmutableMap.builder();
      for (Type type : values()) {
        builder.put(type.code(), type);
      }
      FROM_SHORT = builder.build();
    }

    Type(int code) {
      this.code = Shorts.checkedCast(code);
    }

    public short code() {
      return code;
    }

    public static Type fromCode(short code) {
      return Preconditions.checkNotNull(FROM_SHORT.get(code), "Unknown chunk type: %s", code);
    }
  }

  /** The byte boundary to pad chunks on. */
  public static final int PAD_BOUNDARY = 4;

  /** The number of bytes in every chunk that describes chunk type, header size, and chunk size. */
  public static final int METADATA_SIZE = 8;

  /** The offset in bytes, from the start of the chunk, where the chunk size can be found. */
  private static final int CHUNK_SIZE_OFFSET = 4;

  /** The parent to this chunk, if any. */
  @Nullable
  private final Chunk parent;

  /** Size of the chunk header in bytes. */
  protected final int headerSize;

  /** headerSize + dataSize. The total size of this chunk. */
  protected final int chunkSize;

  /** Offset of this chunk from the start of the file. */
  protected final int offset;

  protected Chunk(ByteBuffer buffer, @Nullable Chunk parent) {
    this.parent = parent;
    offset = buffer.position() - 2;
    headerSize = (buffer.getShort() & 0xFFFF);
    chunkSize = buffer.getInt();
  }

  /**
   * Finishes initialization of a chunk. This should be called immediately after the constructor.
   * This is separate from the constructor so that the header of a chunk can be fully initialized
   * before the payload of that chunk is initialized for chunks that require such behavior.
   *
   * @param buffer The buffer that the payload will be initialized from.
   */
  protected void init(ByteBuffer buffer) {}

  /**
   * Returns the parent to this chunk, if any. A parent is a chunk whose payload contains this
   * chunk. If there's no parent, null is returned.
   */
  @Nullable
  public Chunk getParent() {
    return parent;
  }

  protected abstract Type getType();

  /** Returns the size of this chunk's header. */
  public final int getHeaderSize() {
    return headerSize;
  }

  /**
   * Returns the size of this chunk when it was first read from a buffer. A chunk's size can deviate
   * from this value when its data is modified (e.g. adding an entry, changing a string).
   *
   * <p>A chunk's current size can be determined from the length of the byte array returned from
   * {@link #toByteArray}.
   */
  public final int getOriginalChunkSize() {
    return chunkSize;
  }

  /**
   * Reposition the buffer after this chunk. Use this at the end of a Chunk constructor.
   * @param buffer The buffer to be repositioned.
   */
  private final void seekToEndOfChunk(ByteBuffer buffer) {
    buffer.position(offset + chunkSize);
  }

  /**
   * Writes the type and header size. We don't know how big this chunk will be (it could be
   * different since the last time we checked), so this needs to be passed in.
   *
   * @param output The buffer that will be written to.
   * @param chunkSize The total size of this chunk in bytes, including the header.
   */
  protected final void writeHeader(ByteBuffer output, int chunkSize) {
    int start = output.position();
    output.putShort(getType().code());
    output.putShort((short) headerSize);
    output.putInt(chunkSize);
    writeHeader(output);
    int headerBytes = output.position() - start;
    Preconditions.checkState(headerBytes == getHeaderSize(),
        "Written header is wrong size. Got %s, want %s", headerBytes, getHeaderSize());
  }

  /**
   * Writes the remaining header (after the type, {@code headerSize}, and {@code chunkSize}).
   *
   * @param output The buffer that the header will be written to.
   */
  protected void writeHeader(ByteBuffer output) {}

  /**
   * Writes the chunk payload. The payload is data in a chunk which is not in
   * the first {@code headerSize} bytes of the chunk.
   *
   * @param output The stream that the payload will be written to.
   * @param header The already-written header. This can be modified to fix payload offsets.
   * @param shrink True if this payload should be optimized for size.
   * @throws IOException Thrown if {@code output} could not be written to (out of memory).
   */
  protected void writePayload(DataOutput output, ByteBuffer header, boolean shrink)
      throws IOException {}

  /**
   * Pads {@code output} until {@code currentLength} is on a 4-byte boundary.
   *
   * @param output The {@link DataOutput} that will be padded.
   * @param currentLength The current length, in bytes, of {@code output}
   * @return The new length of {@code output}
   * @throws IOException Thrown if {@code output} could not be written to.
   */
  protected int writePad(DataOutput output, int currentLength) throws IOException {
    while (currentLength % PAD_BOUNDARY != 0) {
      output.write(0);
      ++currentLength;
    }
    return currentLength;
  }

  @Override
  public final byte[] toByteArray() throws IOException {
    return toByteArray(false);
  }

  /**
   * Converts this chunk into an array of bytes representation. Normally you will not need to
   * override this method unless your header changes based on the contents / size of the payload.
   */
  @Override
  public final byte[] toByteArray(boolean shrink) throws IOException {
    ByteBuffer header = ByteBuffer.allocate(getHeaderSize()).order(ByteOrder.LITTLE_ENDIAN);
    writeHeader(header, 0);  // The chunk size isn't known yet. This will be filled in later.
    ByteArrayOutputStream baos = new ByteArrayOutputStream();

    try (LittleEndianDataOutputStream payload = new LittleEndianDataOutputStream(baos)) {
      writePayload(payload, header, shrink);
    }

    byte[] payloadBytes = baos.toByteArray();
    int chunkSize = getHeaderSize() + payloadBytes.length;
    header.putInt(CHUNK_SIZE_OFFSET, chunkSize);

    // Combine results
    ByteBuffer result = ByteBuffer.allocate(chunkSize).order(ByteOrder.LITTLE_ENDIAN);
    result.put(header.array());
    result.put(payloadBytes);
    return result.array();
  }

  /**
   * Creates a new chunk whose contents start at {@code buffer}'s current position.
   *
   * @param buffer A buffer positioned at the start of a chunk.
   * @return new chunk
   */
  public static Chunk newInstance(ByteBuffer buffer) {
    return newInstance(buffer, null);
  }

  /**
   * Creates a new chunk whose contents start at {@code buffer}'s current position.
   *
   * @param buffer A buffer positioned at the start of a chunk.
   * @param parent The parent to this chunk (or null if there's no parent).
   * @return new chunk
   */
  public static Chunk newInstance(ByteBuffer buffer, @Nullable Chunk parent) {
    Chunk result;
    Type type = Type.fromCode(buffer.getShort());
    switch (type) {
      case STRING_POOL:
        result = new StringPoolChunk(buffer, parent);
        break;
      case TABLE:
        result = new ResourceTableChunk(buffer, parent);
        break;
      case XML:
        result = new XmlChunk(buffer, parent);
        break;
      case XML_START_NAMESPACE:
        result = new XmlNamespaceStartChunk(buffer, parent);
        break;
      case XML_END_NAMESPACE:
        result = new XmlNamespaceEndChunk(buffer, parent);
        break;
      case XML_START_ELEMENT:
        result = new XmlStartElementChunk(buffer, parent);
        break;
      case XML_END_ELEMENT:
        result = new XmlEndElementChunk(buffer, parent);
        break;
      case XML_CDATA:
        result = new XmlCdataChunk(buffer, parent);
        break;
      case XML_RESOURCE_MAP:
        result = new XmlResourceMapChunk(buffer, parent);
        break;
      case TABLE_PACKAGE:
        result = new PackageChunk(buffer, parent);
        break;
      case TABLE_TYPE:
        result = new TypeChunk(buffer, parent);
        break;
      case TABLE_TYPE_SPEC:
        result = new TypeSpecChunk(buffer, parent);
        break;
      case TABLE_LIBRARY:
        result = new LibraryChunk(buffer, parent);
        break;
      default:
        result = new UnknownChunk(buffer, parent);
    }
    result.init(buffer);
    result.seekToEndOfChunk(buffer);
    return result;
  }
}