/*
 * The Alluxio Open Foundation licenses this work under the Apache License, version 2.0
 * (the "License"). You may not use this work except in compliance with the License, which is
 * available at www.apache.org/licenses/LICENSE-2.0
 *
 * This software is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
 * either express or implied, as more fully set forth in the License.
 *
 * See the NOTICE file distributed with this work for information regarding copyright ownership.
 */

package alluxio.conf;

import static java.util.stream.Collectors.toSet;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Maps;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiConsumer;
import javax.annotation.Nullable;
import javax.annotation.concurrent.NotThreadSafe;

/**
 * Provides the source of truth of property values and a unified abstraction to put and get
 * properties, hiding the difference of accessing user-specified properties, the default properties
 * (known at construction time) and the extension properties (known at runtime). This class is
 * supposed to handle the ordering and priority of properties from different sources, whereas the
 * <code>Configuration</code> class is supposed to handle the type conversion on top of the source
 * of truth of the properties.
 *
 * For a given property key, the order of preference of its value is (from highest to lowest)
 *
 * (1) runtime config
 * (2) system properties,
 * (3) properties in the specified file (site-properties),
 * (4) default property values.
 */
@NotThreadSafe
public class AlluxioProperties {
  private static final Logger LOG = LoggerFactory.getLogger(AlluxioProperties.class);

  /**
   * Map of user-specified properties. When key is mapped to Optional.empty(), it indicates no
   * value is set for this key. Note that, ConcurrentHashMap requires not null for key and value.
   */
  private final ConcurrentHashMap<PropertyKey, Optional<Object>> mUserProps =
      new ConcurrentHashMap<>();
  /** Map of property sources. */
  private final ConcurrentHashMap<PropertyKey, Source> mSources = new ConcurrentHashMap<>();

  private final Hash mHash = new Hash(() -> keySet().stream()
      .filter(key -> get(key) != null)
      .sorted(Comparator.comparing(PropertyKey::getName))
      .map(key -> String.format("%s:%s:%s", key.getName(), get(key), getSource(key)).getBytes()));

  /**
   * Constructs a new instance of Alluxio properties.
   */
  public AlluxioProperties() {}

  /**
   * @param alluxioProperties properties to copy
   */
  public AlluxioProperties(AlluxioProperties alluxioProperties) {
    mUserProps.putAll(alluxioProperties.mUserProps);
    mSources.putAll(alluxioProperties.mSources);
  }

  /**
   * @param key the key to query
   * @return the value, or null if the key has no value set
   */
  @Nullable
  public Object get(PropertyKey key) {
    if (mUserProps.containsKey(key)) {
      return mUserProps.get(key).orElse(null);
    }
    // In case key is not the reference to the original key
    return PropertyKey.fromString(key.toString()).getDefaultValue();
  }

  /**
   * Clears all existing user-specified properties.
   */
  public void clear() {
    mUserProps.clear();
    mSources.clear();
  }

  /**
   * Puts the key value pair specified by users.
   *
   * @param key key to put
   * @param value value to put
   * @param source the source of this value for the key
   */
  public void put(PropertyKey key, Object value, Source source) {
    if (!mUserProps.containsKey(key) || source.compareTo(getSource(key)) >= 0) {
      mUserProps.put(key, Optional.ofNullable(value));
      mSources.put(key, source);
      mHash.markOutdated();
    }
  }

  /**
   * Puts the key value pair specified by users.
   *
   * @param key key to put
   * @param value value to put
   */
  public void set(PropertyKey key, Object value) {
    put(key, value, Source.RUNTIME);
  }

  /**
   * Merges the current configuration properties with new properties. If a property exists
   * both in the new and current configuration, the one from the new configuration wins if
   * its priority is higher or equal than the existing one.
   *
   * @param properties the source {@link Properties} to be merged
   * @param source the source of the the properties (e.g., system property, default and etc)
   */
  public void merge(Map<?, ?> properties, Source source) {
    if (properties == null || properties.isEmpty()) {
      return;
    }
    // merge the properties
    for (Map.Entry<?, ?> entry : properties.entrySet()) {
      String key = entry.getKey().toString().trim();
      String value = entry.getValue() == null ? null : entry.getValue().toString().trim();
      PropertyKey propertyKey;
      if (PropertyKey.isValid(key)) {
        propertyKey = PropertyKey.fromString(key);
      } else {
        // Add unrecognized properties
        LOG.debug("Property {} from source {} is unrecognized", key, source);
        // Workaround for issue https://alluxio.atlassian.net/browse/ALLUXIO-3108
        // This will register the key as a valid PropertyKey
        // TODO(adit): Do not add properties unrecognized by Ufs extensions when Configuration
        // is made dynamic
        propertyKey = PropertyKey.getOrBuildCustom(key);
      }
      put(propertyKey, propertyKey.parseValue(value), source);
    }
    mHash.markOutdated();
  }

  /**
   * Merges properties from another instance. If a property exists
   * both in the new and current configuration, the one from the new configuration wins if
   * its priority is higher or equal than the existing one.
   *
   * @param properties the properties to merge from
   */
  public void merge(AlluxioProperties properties) {
    if (properties == null || properties.userKeySet().isEmpty()) {
      return;
    }
    for (Map.Entry<PropertyKey, Optional<Object>> entry : properties.mUserProps.entrySet()) {
      PropertyKey key = entry.getKey();
      Optional<Object> value = entry.getValue();
      Source thatSource = properties.getSource(entry.getKey());
      Source thisSource = getSource(key);
      if (!mUserProps.containsKey(key) || thatSource.compareTo(thisSource) >= 0) {
        mUserProps.put(key, value);
        mSources.put(key, thatSource);
      }
    }
    mHash.markOutdated();
  }

  /**
   * Remove the value set for key.
   *
   * @param key key to remove
   */
  public void remove(PropertyKey key) {
    // remove is a nop if the key doesn't already exist
    if (mUserProps.containsKey(key)) {
      mUserProps.remove(key);
      mSources.remove(key);
      mHash.markOutdated();
    }
  }

  /**
   * Checks if there is a value set for the given key.
   *
   * @param key the key to check
   * @return true if there is value for the key, false otherwise
   */
  public boolean isSet(PropertyKey key) {
    if (mUserProps.containsKey(key)) {
      Optional<Object> val = mUserProps.get(key);
      if (val.isPresent()) {
        return true;
      }
    }
    // In case key is not the reference to the original key
    return PropertyKey.fromString(key.toString()).getDefaultValue() != null;
  }

  /**
   * @param key the key to check
   * @return true if there is a value for the key set by user, false otherwise even when there is a
   *         default value for the key
   */
  public boolean isSetByUser(PropertyKey key) {
    if (mUserProps.containsKey(key)) {
      Optional<Object> val = mUserProps.get(key);
      // Sources larger than Source.CLUSTER_DEFAULT are considered to be set by the user
      return val.isPresent() && (getSource(key).compareTo(Source.CLUSTER_DEFAULT) > 0);
    }
    return false;
  }

  /**
   * @return the entry set of all Alluxio property key and value pairs (value can be null)
   */
  public Set<Map.Entry<PropertyKey, Object>> entrySet() {
    return keySet().stream().map(key -> Maps.immutableEntry(key, get(key))).collect(toSet());
  }

  /**
   * @return the key set of all Alluxio property
   */
  public Set<PropertyKey> keySet() {
    Set<PropertyKey> keySet = new HashSet<>(PropertyKey.defaultKeys());
    keySet.addAll(mUserProps.keySet());
    return Collections.unmodifiableSet(keySet);
  }

  /**
   * @return the key set of user set properties
   */
  public Set<PropertyKey> userKeySet() {
    return Collections.unmodifiableSet(mUserProps.keySet());
  }

  /**
   * Iterates over all the key value pairs and performs the given action.
   *
   * @param action the operation to perform on each key value pair
   */
  public void forEach(BiConsumer<? super PropertyKey, ? super Object> action) {
    for (Map.Entry<PropertyKey, Object> entry : entrySet()) {
      action.accept(entry.getKey(), entry.getValue());
    }
  }

  /**
   * Makes a copy of the backing properties and returns them in a new object.
   *
   * @return a copy of the current properties
   */
  public AlluxioProperties copy() {
    return new AlluxioProperties(this);
  }

  /**
   * Sets the source for a given key.
   *
   * @param key property key
   * @param source the source
   */
  @VisibleForTesting
  public void setSource(PropertyKey key, Source source) {
    mSources.put(key, source);
    mHash.markOutdated();
  }

  /**
   * @param key property key
   * @return the source of the key
   */
  public Source getSource(PropertyKey key) {
    Source source = mSources.get(key);
    if (source != null) {
      return source;
    }
    return Source.DEFAULT;
  }

  /**
   * @return the current hash of the properties
   */
  public String hash() {
    return mHash.get();
  }

  /**
   * @return the last update time of the properties
   */
  public long getLastUpdateTime() {
    return mHash.getLastUpdateTime();
  }
}
