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

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutput;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

import org.apache.commons.lang.ArrayUtils;
import org.apache.hadoop.io.Text;
import org.apache.helix.AccessOption;
import org.apache.helix.manager.zk.ByteArraySerializer;
import org.apache.helix.store.HelixPropertyStore;
import org.apache.helix.store.zk.ZkHelixPropertyStore;

import com.google.common.base.Predicate;
import com.google.common.base.Predicates;
import com.google.common.base.Strings;
import com.google.common.collect.Lists;

import org.apache.gobblin.configuration.State;
import org.apache.gobblin.util.io.StreamUtils;
import org.apache.helix.zookeeper.zkclient.serialize.ZkSerializer;


/**
 * An implementation of {@link StateStore} backed by ZooKeeper.
 *
 * <p>
 *
 *     This implementation stores serialized {@link State}s as a blob in ZooKeeper in the Sequence file format.
 *     The ZK path is in the format /STORE_ROOT_DIR/STORE_NAME/TABLE_NAME.
 *     State keys are state IDs (see {@link State#getId()}), and values are objects of {@link State} or
 *     any of its extensions. Keys will be empty strings if state IDs are not set
 *     (i.e., {@link State#getId()} returns <em>null</em>). In this case, the
 *     {@link ZkStateStore#get(String, String, String)} method may not work.
 * </p>
 *
 * @param <T> state object type
 **/
public class ZkStateStore<T extends State> implements StateStore<T> {
  /**
   * Corresponds to {@link AccessOption}, which defines behavior for accessing znodes (get, remove, exists). The value 0 means not to
   * throws exceptions if the znode does not exist (i.e. do not enable {@link AccessOption#THROW_EXCEPTION_IFNOTEXIST}
   *
   * Note: This variable is not be used for create calls like {@link HelixPropertyStore#create(String, Object, int)}
   * which require specifying if the znode is {@link AccessOption#PERSISTENT}, {@link AccessOption#EPHEMERAL}, etc.
   **/
  private static final int DEFAULT_OPTION = 0;

  // Class of the state objects to be put into the store
  private final Class<T> stateClass;
  private final HelixPropertyStore<byte[]> propStore;
  private final boolean compressedValues;

  /**
   * State store that stores instances of {@link State}s in a ZooKeeper-backed {@link HelixPropertyStore}
   * storeRootDir will be created when the first entry is written if it does not exist
   * @param connectString ZooKeeper connect string
   * @param storeRootDir The root directory for the state store
   * @param compressedValues should values be compressed for storage?
   * @param stateClass The type of state being stored
   * @throws IOException
   */
  public ZkStateStore(String connectString, String storeRootDir, boolean compressedValues, Class<T> stateClass)
      throws IOException {
    this.compressedValues = compressedValues;
    this.stateClass = stateClass;

    ZkSerializer serializer = new ByteArraySerializer();
    propStore = new ZkHelixPropertyStore<byte[]>(connectString, serializer, storeRootDir);
  }

  private String formPath(String storeName) {
    return "/" + storeName;
  }

  private String formPath(String storeName, String tableName) {
    return "/" + storeName + "/" + tableName;
  }

  @Override
  public boolean create(String storeName) throws IOException {
    String path = formPath(storeName);

    return propStore.exists(path, DEFAULT_OPTION) || propStore.create(path, ArrayUtils.EMPTY_BYTE_ARRAY,
        AccessOption.PERSISTENT);
  }

  @Override
  public boolean create(String storeName, String tableName) throws IOException {
    String path = formPath(storeName, tableName);

    if (propStore.exists(path, DEFAULT_OPTION)) {
      throw new IOException(String.format("State already exists for storeName %s tableName %s", storeName,
          tableName));
    }

    return propStore.create(path, ArrayUtils.EMPTY_BYTE_ARRAY, AccessOption.PERSISTENT);
  }

  @Override
  public boolean exists(String storeName, String tableName) throws IOException {
    String path = formPath(storeName, tableName);

    return propStore.exists(path, DEFAULT_OPTION);
  }

  /**
   * Serializes the state to the {@link DataOutput}
   * @param dataOutput output target receiving the serialized data
   * @param state the state to serialize
   * @throws IOException
   */
  private void addStateToDataOutputStream(DataOutput dataOutput, T state) throws IOException {
    new Text(Strings.nullToEmpty(state.getId())).write(dataOutput);
    state.write(dataOutput);
  }

  /**
   * Create a new znode with data if it does not exist otherwise update with data
   * @param storeName storeName portion of znode path
   * @param tableName tableName portion of znode path
   * @param data znode data
   * @throws IOException
   */
  private void putData(String storeName, String tableName, byte[] data) throws IOException {
    String path = formPath(storeName, tableName);

    if (!propStore.exists(path, DEFAULT_OPTION)) {
      // create with data
      if (!propStore.create(path, data, AccessOption.PERSISTENT)) {
        throw new IOException("Failed to create a state file for table " + tableName);
      }
    } else {
      // Update
      propStore.set(path, data, AccessOption.PERSISTENT);
    }
  }

  @Override
  public void put(String storeName, String tableName, T state) throws IOException {
    putAll(storeName, tableName, Collections.singletonList(state));
  }

  @Override
  public void putAll(String storeName, String tableName, Collection<T> states) throws IOException {
    try (ByteArrayOutputStream byteArrayOs = new ByteArrayOutputStream();
        OutputStream os = compressedValues ? new GZIPOutputStream(byteArrayOs) : byteArrayOs;
        DataOutputStream dataOutput = new DataOutputStream(os)) {

      for (T state : states) {
        addStateToDataOutputStream(dataOutput, state);
      }

      dataOutput.close();
      putData(storeName, tableName, byteArrayOs.toByteArray());
    }
  }

  @Override
  public T get(String storeName, String tableName, String stateId) throws IOException {
    String path = formPath(storeName, tableName);
    byte[] data = propStore.get(path, null, DEFAULT_OPTION);
    List<T> states = Lists.newArrayList();

    deserialize(data, states, stateId);

    if (states.isEmpty()) {
      return null;
    } else {
      return states.get(DEFAULT_OPTION);
    }
  }

  /**
   * Retrieve states from the state store based on the store name and a filtering predicate
   * @param storeName The store name enclosing the state files
   * @param predicate The predicate for state file filtering
   * @return list of states matching matching the predicate
   * @throws IOException
   */
  protected List<T> getAll(String storeName, Predicate<String> predicate) throws IOException {
    List<T> states = Lists.newArrayList();
    String path = formPath(storeName);
    byte[] data;

    List<String> children = propStore.getChildNames(path, DEFAULT_OPTION);

    if (children == null) {
      return Collections.emptyList();
    }

    for (String c : children) {
      if (predicate.apply(c)) {
        data = propStore.get(path + "/" + c, null, DEFAULT_OPTION);
        deserialize(data, states);
      }
    }

    return states;
  }

  @Override
  public List<T> getAll(String storeName, String tableName) throws IOException {
    List<T> states = Lists.newArrayList();
    String path = formPath(storeName, tableName);
    byte[] data = propStore.get(path, null, DEFAULT_OPTION);

    deserialize(data, states);

    return states;
  }

  @Override
  public List<T> getAll(String storeName) throws IOException {
    return getAll(storeName, Predicates.<String>alwaysTrue());
  }

  @Override
  public List<String> getTableNames(String storeName, Predicate<String> predicate) throws IOException {
    List<String> names = Lists.newArrayList();
    String path = formPath(storeName);

    List<String> children = propStore.getChildNames(path, DEFAULT_OPTION);

    if (children != null) {
      for (String c : children) {
        if (predicate.apply(c)) {
          names.add(c);
        }
      }
    }

    return names;
  }

  /**
   * Get store names in the state store
   *
   * @param predicate only returns names matching predicate
   * @return (possibly empty) list of store names from the given store
   * @throws IOException
   */
  public List<String> getStoreNames(Predicate<String> predicate)
      throws IOException {
    List<String> names = Lists.newArrayList();
    String path = formPath("");

    List<String> children = propStore.getChildNames(path, DEFAULT_OPTION);

    if (children != null) {
      for (String c : children) {
        if (predicate.apply(c)) {
          names.add(c);
        }
      }
    }

    return names;
  }

  @Override
  public void createAlias(String storeName, String original, String alias) throws IOException {
    String pathOriginal = formPath(storeName, original);
    byte[] data;

    if (!propStore.exists(pathOriginal, DEFAULT_OPTION)) {
      throw new IOException(String.format("State does not exist for table %s", original));
    }

    data = propStore.get(pathOriginal, null, DEFAULT_OPTION);

    putData(storeName, alias, data);
  }

  @Override
  public void delete(String storeName, String tableName) throws IOException {
    propStore.remove(formPath(storeName, tableName), DEFAULT_OPTION);
  }

  @Override
  public void delete(String storeName, List<String> tableNames) throws IOException {
    List<String> paths = tableNames.stream().map(table -> formPath(storeName, table)).collect(Collectors.toList());
    propStore.remove(paths, DEFAULT_OPTION);
  }

  @Override
  public void delete(String storeName) throws IOException {
    propStore.remove(formPath(storeName), DEFAULT_OPTION);
  }

  /**
   * Deserialize data into a list of {@link State}s.
   * @param data byte array
   * @param states output list of states
   * @param stateId optional key filter. Set to null for no filtering.
   * @throws IOException
   */
  private void deserialize(byte[] data, List<T> states, String stateId) throws IOException {
    if (data != null) {
      Text key = new Text();

      try (ByteArrayInputStream bais = new ByteArrayInputStream(data);
          InputStream is = StreamUtils.isCompressed(data) ? new GZIPInputStream(bais) : bais;
          DataInputStream dis = new DataInputStream(is)){
        // keep deserializing while we have data
        while (dis.available() > 0) {
          T state = this.stateClass.newInstance();

          key.readFields(dis);
          state.readFields(dis);
          state.setId(key.toString());
          states.add(state);

          if (stateId != null && key.toString().equals(stateId)) {
            return;
          }
        }
      } catch (EOFException e) {
        // no more data. GZIPInputStream.available() doesn't return 0 until after EOF.
      } catch (RuntimeException e) {
        throw e;
      } catch (Exception e) {
        throw new IOException("failure deserializing state from ZkStateStore", e);
      }
    }
  }

  /**
   * Deserialize data into a list of {@link State}s.
   * @param data byte array
   * @param states output list of states
   * @throws IOException
   */
  private void deserialize(byte[] data, List<T> states) throws IOException {
    deserialize(data, states, null);
  }
}
