/*
 * 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.gobblin.crypto;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Map;
import java.util.Random;

import org.apache.commons.io.IOUtils;
import org.testng.Assert;
import org.testng.annotations.Test;

import com.google.common.collect.ImmutableMap;

import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;


public class RotatingAESCodecTest {
  @Test
  public void testStreams()
      throws IOException, NoSuchAlgorithmException, NoSuchPaddingException, InvalidKeyException,
             InvalidAlgorithmParameterException {
    final byte[] toWrite = "hello world".getBytes();

    SimpleCredentialStore credStore = new SimpleCredentialStore();
    RotatingAESCodec encryptor = new RotatingAESCodec(credStore);
    ByteArrayOutputStream sink = new ByteArrayOutputStream();
    OutputStream os = encryptor.encodeOutputStream(sink);
    os.write(toWrite);
    os.close();
    byte[] encryptedBytes = sink.toByteArray();

    manuallyDecodeAndVerifyBytes(toWrite, encryptedBytes, credStore);

    // Try with stream
    InputStream decoderIn = encryptor.decodeInputStream(new ByteArrayInputStream(encryptedBytes));
    byte[] decoded = IOUtils.toByteArray(decoderIn);
    Assert.assertEquals(decoded, toWrite, "Expected decoded output to match encoded output");
  }

  @Test
  public void testLotsOfData()
      throws Exception {
    long bytesToWrite = 20 * 1000 * 1000;
    byte[] buf = new byte[16384];
    long bytesWritten = 0;
    SimpleCredentialStore credStore = new SimpleCredentialStore();
    RotatingAESCodec encryptor = new RotatingAESCodec(credStore);
    ByteArrayOutputStream sink = new ByteArrayOutputStream();
    ByteArrayOutputStream originalBytesStream = new ByteArrayOutputStream();
    OutputStream encryptedStream = encryptor.encodeOutputStream(sink);
    Random r = new Random();

    while (bytesWritten < bytesToWrite) {
      r.nextBytes(buf);
      originalBytesStream.write(buf);
      encryptedStream.write(buf);
      bytesWritten += buf.length;
    }

    encryptedStream.close();
    byte[] originalBytes = originalBytesStream.toByteArray();
    byte[] encryptedBytes = sink.toByteArray();

    manuallyDecodeAndVerifyBytes(originalBytes, encryptedBytes, credStore);

    // Try with stream
    InputStream decoderIn = encryptor.decodeInputStream(new ByteArrayInputStream(encryptedBytes));
    byte[] decoded = IOUtils.toByteArray(decoderIn);
    Assert.assertEquals(decoded, originalBytes, "Expected decoded output to match encoded output");
  }

  private byte[] readAndBase64DecodeBody(InputStream in)
      throws IOException {
    byte[] body = IOUtils.toByteArray(in);
    body = DatatypeConverter.parseBase64Binary(new String(body, "UTF-8"));
    return body;
  }

  private byte[] verifyAndExtractIv(InputStream in, Integer ivLen)
      throws IOException {
    int bytesRead;
    byte[] base64Iv = new byte[ivLen];
    bytesRead = in.read(base64Iv);
    Assert.assertEquals(Integer.valueOf(bytesRead), Integer.valueOf(ivLen), "Expected to read IV");
    return DatatypeConverter.parseBase64Binary(new String(base64Iv, "UTF-8"));
  }

  private Integer verifyIvLen(InputStream in)
      throws IOException {
    int bytesRead;
    byte[] ivLenBytes = new byte[3];
    bytesRead = in.read(ivLenBytes);
    Assert.assertEquals(bytesRead, ivLenBytes.length, "Expected to be able to iv length");
    Integer ivLen = Integer.valueOf(new String(ivLenBytes, "UTF-8"));
    Assert.assertEquals(Integer.valueOf(ivLen), Integer.valueOf(24), "Always expect IV to be 24 bytes base64 encoded");
    return ivLen;
  }

  private void verifyKeyId(InputStream in, int expectedKeyId)
      throws IOException {
    // Verify keyId is properly padded
    byte[] keyIdBytes = new byte[4];
    int bytesRead = in.read(keyIdBytes);
    Assert.assertEquals(bytesRead, keyIdBytes.length, "Expected to be able to read key id");
    String keyId = new String(keyIdBytes, "UTF-8");
    Assert.assertEquals(Integer.valueOf(keyId), Integer.valueOf(expectedKeyId), "Expected keyId to equal 1");
  }

  private void manuallyDecodeAndVerifyBytes(byte[] originalBytes, byte[] encryptedBytes,
      SimpleCredentialStore credStore)
      throws IOException, NoSuchAlgorithmException, NoSuchPaddingException, InvalidKeyException,
             InvalidAlgorithmParameterException {
    // Manually decode
    InputStream in = new ByteArrayInputStream(encryptedBytes);
    verifyKeyId(in, 1);

    Integer ivLen = verifyIvLen(in);

    byte[] ivBinary = verifyAndExtractIv(in, ivLen);

    byte[] body = readAndBase64DecodeBody(in);

    // feed back into cipheroutput stream
    Cipher inputCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    IvParameterSpec ivParameterSpec = new IvParameterSpec(ivBinary);
    inputCipher.init(Cipher.DECRYPT_MODE, credStore.getKey(), ivParameterSpec);

    CipherInputStream cis = new CipherInputStream(new ByteArrayInputStream(body), inputCipher);
    byte[] decoded = IOUtils.toByteArray(cis);
    Assert.assertEquals(decoded, originalBytes, "Expected decoded output to match encoded output");
  }

  static class SimpleCredentialStore implements CredentialStore {
    private final SecretKey key;

    public SimpleCredentialStore() {
      SecureRandom r = new SecureRandom();
      byte[] keyBytes = new byte[16];
      r.nextBytes(keyBytes);

      key = new SecretKeySpec(keyBytes, "AES");
    }

    @Override
    public byte[] getEncodedKey(String id) {
      if (id.equals("1")) {
        return key.getEncoded();
      }

      return null;
    }

    @Override
    public Map<String, byte[]> getAllEncodedKeys() {
      return ImmutableMap.of("1", key.getEncoded());
    }

    public SecretKey getKey() {
      return key;
    }
  }
}
