/*
 * Copyright 2015 Jo Rabin
 *
 * 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.intellij.credentialStore.kdbx;

import org.jetbrains.annotations.NotNull;

import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.OutputStream;
import java.security.MessageDigest;

/**
 * Takes a stream of data and formats as Hashed Blocks to the underlying output stream.
 * <p>
 * <p>A Hashed block consists of:
 * <p>
 * <ol>
 * <li>A 4 byte block sequence number, increments from 0
 * <li>A 32 byte MD5 hash of the content
 * <li>A 4 byte length field
 * <li>Content
 * </ol>
 * <p>
 * <p>The stream of blocks is terminated with a 0 length 0 hash block.
 * <p>
 * <p>Originally developed for KeePass. A KeePass hash block
 * stream is little endian, i.e. the sequence
 * number and length fields are low order byte first.
 *
 * @author Jo
 */
public class HashedBlockOutputStream extends OutputStream {
  private static final int BLOCK_SIZE = 8 * 1024;
  private static final int HASH_SIZE = 32;
  private static final byte[] ZERO_HASH = new byte[HASH_SIZE];

  private int nextSequenceNumber = 0;
  private OutputStream outputStream;
  private ByteArrayOutputStream blockOutputStream = new ByteArrayOutputStream();
  private boolean isClosed = false;

  private final MessageDigest md = KdbxHeaderKt.sha256MessageDigest();

  public HashedBlockOutputStream(OutputStream outputStream) {
    this.outputStream = outputStream;
  }

  @Override
  public void write(int i) throws IOException {
    byte[] buf = new byte[1];
    buf[0] = (byte)i;
    put(buf, 0, 1);
  }

  @Override
  public void write(@NotNull byte[] b, int offset, int count) throws IOException {
    put(b, offset, count);
  }


  @Override
  public void flush() throws IOException {
    save();
  }

  @Override
  public void close() throws IOException {
    if (isClosed) {
      throw new EOFException();
    }
    flush();
    writeInt(nextSequenceNumber);
    outputStream.write(ZERO_HASH);
    writeInt(0);
    isClosed = true;
    outputStream.flush();
    outputStream.close();
  }

  /**
   * Writes to the internal buffer, and writes to the underlying output stream
   * as necessary as {@link #BLOCK_SIZE} blocks
   *
   * @param b      the byte array to write
   * @param offset offset in the byte array
   * @param length number of bytes to write
   * @throws IOException
   */
  protected void put(byte[] b, int offset, int length) throws IOException {
    if (isClosed) {
      throw new EOFException();
    }
    while (length > 0) {
      int bytesToWrite = Math.min(BLOCK_SIZE - blockOutputStream.size(), length);
      blockOutputStream.write(b, offset, bytesToWrite);
      if (blockOutputStream.size() >= BLOCK_SIZE) {
        save();
      }
      offset += bytesToWrite;
      length -= bytesToWrite;
    }
  }

  /**
   * Save the internal buffer to the underlying stream as a hash block
   */
  protected void save() throws IOException {
    // if there's nothing to save don't do anything
    if (blockOutputStream.size() == 0) {
      return;
    }
    // write and increment the block sequence no
    writeInt(nextSequenceNumber++);

    // calculate the hash of the buffer
    byte[] buffer = blockOutputStream.toByteArray();
    md.update(buffer);
    outputStream.write(md.digest());

    // write the buffer's length
    writeInt(buffer.length);

    // write the buffer
    outputStream.write(buffer);

    // push the contents to disk etc.
    outputStream.flush();

    // reset the internal output buffer for reuse
    blockOutputStream.reset();
  }

  /**
   * Write a 4 byte int value to the underlying stream in appropriate endian format
   *
   * @param value the value to write
   * @throws IOException
   */
  protected void writeInt(int value) throws IOException {
    int output = Integer.reverseBytes(value);
    outputStream.write(new byte[]{(byte)(output >> 24), (byte)(output >> 16), (byte)(output >> 8), (byte)output});
  }
}
