/*
 * 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.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.Key;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.util.EnumSet;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import lombok.extern.slf4j.Slf4j;


/**
 * A CredentialStore that uses Java's Keystore implementation to store and retrieve keys.
 */
@Slf4j
public class JCEKSKeystoreCredentialStore implements CredentialStore {
  public final static String TAG = "java";

  private final KeyStore ks;
  private final char[] password;
  private final Path path;
  private final FileSystem fs;
  /**
   * Options that can be used while instantiating a new keystore
   */
  public enum CreationOptions {
    /**
     * Create an empty keystore if one can't be found. Otherwise an exception will be thrown.
     */
    CREATE_IF_MISSING
  }

  /**
   * Instantiate a new keystore at the given path protected by a password.
   * @param path Path to find the keystore
   * @param passwordStr Password the keystore is protected with
   * @throws IOException If the keystore cannot be loaded because the password is wrong or the file has been corrupted.
   * @throws IllegalArgumentException If the keystore does not exist at the given path.
   */
  public JCEKSKeystoreCredentialStore(String path, String passwordStr) throws IOException {
    this(path, passwordStr, EnumSet.noneOf(CreationOptions.class));
  }

  /**
   * Instantiate a new keystore at the given path protected by a password.
   * @param path Path to find the keystore
   * @param passwordStr Password the keystore is protected with
   * @param options Flags for keystore creation
   * @throws IOException If the keystore cannot be loaded because of a corrupt file or the password is wrong
   * @throws IllegalArgumentException If CREATE_IF_MISSING is not present in options and the keystore does not exist
   * at the given path.
   */
  public JCEKSKeystoreCredentialStore(String path, String passwordStr, EnumSet<CreationOptions> options) throws IOException {
    this(new Path(path), passwordStr, options);
  }

  /**
   * Instantiate a new keystore at the given path protected by a password.
   * @param path Path to find the keystore
   * @param passwordStr Password the keystore is protected with
   * @param options Flags for keystore creation
   * @throws IOException If the keystore cannot be loaded because of a corrupt file or the password is wrong
   * @throws IllegalArgumentException If CREATE_IF_MISSING is not present in options and the keystore does not exist
   * at the given path.
   */
  public JCEKSKeystoreCredentialStore(Path path, String passwordStr, EnumSet<CreationOptions> options) throws IOException {
    try {
      this.ks = KeyStore.getInstance("JCEKS");
      this.password = passwordStr.toCharArray();
      this.path = path;
      this.fs = path.getFileSystem(new Configuration());

      if (!fs.exists(path)) {
        if (options.contains(CreationOptions.CREATE_IF_MISSING)) {
          log.info("No keystore found at " + path + ", creating from scratch");
          ks.load(null, password);
        } else {
          throw new IllegalArgumentException("Keystore " + path + " does not exist");
        }
      } else {
        try (InputStream fis = fs.open(path)) {
          ks.load(fis, password);
          log.info("Successfully loaded keystore from " + path);
        }
      }
    } catch (KeyStoreException | CertificateException | NoSuchAlgorithmException e) {
      throw new IllegalStateException("Unexpected failure initializing keystore", e);
    }
  }

  @Override
  public byte[] getEncodedKey(String id) {
    try {
      Key k = ks.getKey(id, password);
      return (k == null) ? null : k.getEncoded();
    } catch (KeyStoreException | NoSuchAlgorithmException | UnrecoverableKeyException e) {
      log.warn("Error trying to decode key " + id, e);
      return null;
    }
  }

  @Override
  public Map<String, byte[]> getAllEncodedKeys() {
    Map<String, byte[]> ret = new HashMap<>();
    try {
      Enumeration<String> aliases = ks.aliases();
      while (aliases.hasMoreElements()) {
        String key = aliases.nextElement();
        try {
          if (ks.isKeyEntry(key)) {
            ret.put(key, getEncodedKey(key));
          }
        } catch (KeyStoreException e) {
          log.warn("Error trying to decode key id " + key + ", not returning in list", e);
        }
      }
    } catch (KeyStoreException e) {
      log.warn("Error retrieving all aliases in keystore; treating as empty", e);
      return ret;
    }

    return ret;
  }

  /**
   * Generate a set of AES keys for the store. The key ids will simple be (startOffset ... startOffset + numKeys).
   * @param numKeys Number of keys to generate
   * @param startOffset ID to start generating keys with
   * @throws IOException If there is an error serializing the keystore back to disk
   * @throws KeyStoreException If there is an error serializing the keystore back to disk
   */
  public void generateAesKeys(int numKeys, int startOffset) throws IOException, KeyStoreException {
    for (int i = 1; i <= numKeys; i++) {
      SecretKey key = generateKey();
      ks.setEntry(String.valueOf(i + startOffset), new KeyStore.SecretKeyEntry(key),
          new KeyStore.PasswordProtection(password));
    }

    saveKeystore();
  }

  private SecretKey generateKey() {
    SecureRandom r = new SecureRandom();
    byte[] keyBytes = new byte[16];
    r.nextBytes(keyBytes);

    return new SecretKeySpec(keyBytes, "AES");
  }

  private void saveKeystore() throws IOException {
    try (OutputStream fOs = fs.create(path, true)) {
      ks.store(fOs, password);
    } catch (KeyStoreException | NoSuchAlgorithmException | CertificateException e) {
      throw new IOException("Error serializing keystore", e);
    }
  }
}
