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

import java.io.IOException;
import java.io.StringReader;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import org.apache.commons.lang3.StringUtils;

import com.google.common.base.Function;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.gson.Gson;
import com.opencsv.CSVReader;
import com.typesafe.config.Config;
import com.typesafe.config.ConfigException;
import com.typesafe.config.ConfigFactory;
import com.typesafe.config.ConfigList;
import com.typesafe.config.ConfigValue;

import org.apache.gobblin.configuration.State;
import org.apache.gobblin.password.PasswordManager;


/**
 * Utility class for dealing with {@link Config} objects.
 */
public class ConfigUtils {
  private final FileUtils fileUtils;

  /**
   * List of keys that should be excluded when converting to typesafe config.
   * Usually, it is the key that is both the parent object of a value and a value, which is disallowed by Typesafe.
   */
  private static final String GOBBLIN_CONFIG_BLACKLIST_KEYS = "gobblin.config.blacklistKeys";

  /**
   * A suffix that is automatically appended to property keys that are prefixes of other
   * property keys. This is used during Properties -> Config -> Properties conversion since
   * typesafe config does not allow such properties. */
  public static final String STRIP_SUFFIX = ".ROOT_VALUE";

  /**
   * Available TimeUnit values that can be parsed from a given String
   */
  private static final Set<String> validTimeUnits = Arrays.stream(TimeUnit.values())
      .map(TimeUnit::name)
      .collect(Collectors.toSet());

  public ConfigUtils(FileUtils fileUtils) {
    this.fileUtils = fileUtils;
  }

  public void saveConfigToFile(final Config config, final Path destPath)
      throws IOException {
    final String configAsHoconString = config.root().render();
    this.fileUtils.saveToFile(configAsHoconString, destPath);
  }

  /**
   * Convert a given {@link Config} instance to a {@link Properties} instance.
   *
   * @param config the given {@link Config} instance
   * @return a {@link Properties} instance
   */
  public static Properties configToProperties(Config config) {
    return configToProperties(config, Optional.absent());
  }

  /**
   * Convert a given {@link Config} instance to a {@link Properties} instance.
   * If the config value is not of String type, it will try to get it as a generic Object type
   * using {@see com.typesafe.config.Config#getAnyRef()} and then try to return its json representation as a string
   *
   * @param config the given {@link Config} instance
   * @param prefix an optional prefix; if present, only properties whose name starts with the prefix
   *        will be returned.
   * @return a {@link Properties} instance
   */
  public static Properties configToProperties(Config config, Optional<String> prefix) {
    Properties properties = new Properties();
    if (config != null) {
      Config resolvedConfig = config.resolve();
      for (Map.Entry<String, ConfigValue> entry : resolvedConfig.entrySet()) {
        if (!prefix.isPresent() || entry.getKey().startsWith(prefix.get())) {
          String propKey = desanitizeKey(entry.getKey());
          String propVal;
          try {
            propVal = resolvedConfig.getString(entry.getKey());
          } catch (ConfigException.WrongType wrongType) {
            propVal = new Gson().toJson(resolvedConfig.getAnyRef(entry.getKey()));
          }
          properties.setProperty(propKey, propVal);
        }
      }
    }

    return properties;
  }

  /**
   * Convert a given {@link Config} instance to a {@link Properties} instance.
   *
   * @param config the given {@link Config} instance
   * @param prefix only properties whose name starts with the prefix will be returned.
   * @return a {@link Properties} instance
   */
  public static Properties configToProperties(Config config, String prefix) {
    return configToProperties(config, Optional.of(prefix));
  }

  /**
   * @return the subconfig under key "key" if it exists, otherwise returns an empty config.
   */
  public static Config getConfigOrEmpty(Config config, String key) {
    try {
      if (config.hasPath(key)) {
        return config.getConfig(key);
      } else {
        return ConfigFactory.empty();
      }
    } catch (ConfigException.WrongType wrongType) {
      // path exists, but it is not a subconfig
      return ConfigFactory.empty();
    }
  }

  /**
   * Convert a given {@link Config} to a {@link State} instance.
   *
   * @param config the given {@link Config} instance
   * @return a {@link State} instance
   */
  public static State configToState(Config config) {
    return new State(configToProperties(config));
  }

  /**
   * Convert a given {@link Properties} to a {@link Config} instance.
   *
   * <p>
   *   This method will throw an exception if (1) the {@link Object#toString()} method of any two keys in the
   *   {@link Properties} objects returns the same {@link String}, or (2) if any two keys are prefixes of one another,
   *   see the Java Docs of {@link ConfigFactory#parseMap(Map)} for more details.
   * </p>
   *
   * @param properties the given {@link Properties} instance
   * @return a {@link Config} instance
   */
  public static Config propertiesToConfig(Properties properties) {
    return propertiesToConfig(properties, Optional.absent());
  }

  /**
   * Finds a list of properties whose keys are complete prefix of other keys. This function is
   * meant to be used during conversion from Properties to typesafe Config as the latter does not
   * support this scenario.
   * @param     properties      the Properties collection to inspect
   * @param     keyPrefix       an optional key prefix which limits which properties are inspected.
   * */
  public static Set<String> findFullPrefixKeys(Properties properties,
                                               Optional<String> keyPrefix) {
    TreeSet<String> propNames = new TreeSet<>();
    for (Map.Entry<Object, Object> entry : properties.entrySet()) {
      String entryKey = entry.getKey().toString();
      if (StringUtils.startsWith(entryKey, keyPrefix.or(StringUtils.EMPTY))) {
        propNames.add(entryKey);
      }
    }

    Set<String> result = new HashSet<>();
    String lastKey = null;
    Iterator<String> sortedKeysIter = propNames.iterator();
    while(sortedKeysIter.hasNext()) {
      String propName = sortedKeysIter.next();
      if (null != lastKey && propName.startsWith(lastKey + ".")) {
        result.add(lastKey);
      }
      lastKey = propName;
    }

    return result;
  }

  /**
   * Convert all the keys that start with a <code>prefix</code> in {@link Properties} to a {@link Config} instance.
   *
   * <p>
   *   This method will throw an exception if (1) the {@link Object#toString()} method of any two keys in the
   *   {@link Properties} objects returns the same {@link String}, or (2) if any two keys are prefixes of one another,
   *   see the Java Docs of {@link ConfigFactory#parseMap(Map)} for more details.
   * </p>
   *
   * @param properties the given {@link Properties} instance
   * @param prefix of keys to be converted
   * @return a {@link Config} instance
   */
  public static Config propertiesToConfig(Properties properties, Optional<String> prefix) {
    Set<String> blacklistedKeys = new HashSet<>(0);
    if (properties.containsKey(GOBBLIN_CONFIG_BLACKLIST_KEYS)) {
      blacklistedKeys = new HashSet<>(Splitter.on(',').omitEmptyStrings().trimResults()
          .splitToList(properties.getProperty(GOBBLIN_CONFIG_BLACKLIST_KEYS)));
    }

    Set<String> fullPrefixKeys = findFullPrefixKeys(properties, prefix);

    ImmutableMap.Builder<String, Object> immutableMapBuilder = ImmutableMap.builder();
    for (Map.Entry<Object, Object> entry : properties.entrySet()) {
      String entryKey = entry.getKey().toString();
      if (StringUtils.startsWith(entryKey, prefix.or(StringUtils.EMPTY)) &&
          !blacklistedKeys.contains(entryKey)) {
        if (fullPrefixKeys.contains(entryKey)) {
          entryKey = sanitizeFullPrefixKey(entryKey);
        } else if (sanitizedKey(entryKey)) {
          throw new RuntimeException("Properties are not allowed to end in " + STRIP_SUFFIX);
        }
        immutableMapBuilder.put(entryKey, entry.getValue());
      }
    }
    return ConfigFactory.parseMap(immutableMapBuilder.build());
  }

  public static String sanitizeFullPrefixKey(String propKey) {
    return propKey + STRIP_SUFFIX;
  }

  /**
   * returns true if is it a sanitized key
   */
  public static boolean sanitizedKey(String propKey) {
    return propKey.endsWith(STRIP_SUFFIX);
  }

  public static String desanitizeKey(String propKey) {
    propKey =  sanitizedKey(propKey) ?
        propKey.substring(0, propKey.length() - STRIP_SUFFIX.length()) : propKey;

    // Also strip quotes that can get introduced by TypeSafe.Config
    propKey = propKey.replace("\"", "");
    return propKey;
  }


  /**
   * Convert all the keys that start with a <code>prefix</code> in {@link Properties} to a
   * {@link Config} instance. The method also tries to guess the types of properties.
   *
   * <p>
   *   This method will throw an exception if (1) the {@link Object#toString()} method of any two keys in the
   *   {@link Properties} objects returns the same {@link String}, or (2) if any two keys are prefixes of one another,
   *   see the Java Docs of {@link ConfigFactory#parseMap(Map)} for more details.
   * </p>
   *
   * @param properties the given {@link Properties} instance
   * @param prefix of keys to be converted
   * @return a {@link Config} instance
   */
  public static Config propertiesToTypedConfig(Properties properties, Optional<String> prefix) {
    Map<String, Object> typedProps = guessPropertiesTypes(properties);
    ImmutableMap.Builder<String, Object> immutableMapBuilder = ImmutableMap.builder();
    for (Map.Entry<String, Object> entry : typedProps.entrySet()) {
      if (StringUtils.startsWith(entry.getKey(), prefix.or(StringUtils.EMPTY))) {
        immutableMapBuilder.put(entry.getKey(), entry.getValue());
      }
    }
    return ConfigFactory.parseMap(immutableMapBuilder.build());
  }

  /** Attempts to guess type types of a Properties. By default, typesafe will make all property
   * values Strings. This implementation will try to recognize booleans and numbers. All keys are
   * treated as strings.*/
  private static Map<String, Object> guessPropertiesTypes(Map<Object, Object> srcProperties) {
    Map<String, Object> res = Maps.newHashMapWithExpectedSize(srcProperties.size());
    for (Map.Entry<Object, Object> prop : srcProperties.entrySet()) {
      Object value = prop.getValue();
      if (value instanceof String && !Strings.isNullOrEmpty(value.toString())) {
        try {
          value = Long.parseLong(value.toString());
        } catch (NumberFormatException e) {
          try {
            value = Double.parseDouble(value.toString());
          } catch (NumberFormatException e2) {
            if (value.toString().equalsIgnoreCase("true") || value.toString().equalsIgnoreCase("yes")) {
              value = Boolean.TRUE;
            } else if (value.toString().equalsIgnoreCase("false") || value.toString().equalsIgnoreCase("no")) {
              value = Boolean.FALSE;
            } else {
              // nothing to do
            }
          }
        }
      }
      res.put(prop.getKey().toString(), value);
    }
    return res;
  }

  /**
   * Return string value at <code>path</code> if <code>config</code> has path. If not return an empty string
   *
   * @param config in which the path may be present
   * @param path key to look for in the config object
   * @return string value at <code>path</code> if <code>config</code> has path. If not return an empty string
   */
  public static String emptyIfNotPresent(Config config, String path) {
    return getString(config, path, StringUtils.EMPTY);
  }

  /**
   * Return string value at <code>path</code> if <code>config</code> has path. If not return <code>def</code>
   * If the config value is not of String type, it will try to get it as a generic Object type
   * using {@see com.typesafe.config.Config#getAnyRef()} and then try to return its json representation as a string
   * @param config in which the path may be present
   * @param path key to look for in the config object
   * @return string value at <code>path</code> if <code>config</code> has path. If not return <code>def</code>
   */
  public static String getString(Config config, String path, String def) {
    if (config.hasPath(path)) {
      String value;
      try {
        value = config.getString(path);
      } catch (ConfigException.WrongType wrongType) {
        value = new Gson().toJson(config.getAnyRef(path));
      }
      return value;
    }
    return def;
  }

  /**
   * Return TimeUnit value at <code>path</code> if <code>config</code> has path. If not return <code>def</code>
   *
   * @param config in which the path may be present
   * @param path key to look for in the config object
   * @return TimeUnit value at <code>path</code> if <code>config</code> has path. If not return <code>def</code>
   */
  public static TimeUnit getTimeUnit(Config config, String path, TimeUnit def) {
    if (config.hasPath(path)) {
      String timeUnit = config.getString(path).toUpperCase();
      Preconditions.checkArgument(validTimeUnits.contains(timeUnit),
          String.format("Passed invalid TimeUnit for documentTTLUnits: '%s'", timeUnit));
      return TimeUnit.valueOf(timeUnit);
    }
    return def;
  }

  /**
   * Return {@link Long} value at <code>path</code> if <code>config</code> has path. If not return <code>def</code>
   *
   * @param config in which the path may be present
   * @param path key to look for in the config object
   * @return {@link Long} value at <code>path</code> if <code>config</code> has path. If not return <code>def</code>
   */
  public static Long getLong(Config config, String path, Long def) {
    if (config.hasPath(path)) {
      return config.getLong(path);
    }
    return def;
  }

  /**
   * Return {@link Integer} value at <code>path</code> if <code>config</code> has path. If not return <code>def</code>
   *
   * @param config in which the path may be present
   * @param path key to look for in the config object
   * @return {@link Integer} value at <code>path</code> if <code>config</code> has path. If not return <code>def</code>
   */
  public static Integer getInt(Config config, String path, Integer def) {
    if (config.hasPath(path)) {
      return config.getInt(path);
    }
    return def;
  }

  /**
   * Return boolean value at <code>path</code> if <code>config</code> has path. If not return <code>def</code>
   *
   * @param config in which the path may be present
   * @param path key to look for in the config object
   * @return boolean value at <code>path</code> if <code>config</code> has path. If not return <code>def</code>
   */
  public static boolean getBoolean(Config config, String path, boolean def) {
    if (config.hasPath(path)) {
      return config.getBoolean(path);
    }
    return def;
  }

  /**
   * Return double value at <code>path</code> if <code>config</code> has path. If not return <code>def</code>
   *
   * @param config in which the path may be present
   * @param path key to look for in the config object
   * @return double value at <code>path</code> if <code>config</code> has path. If not return <code>def</code>
   */
  public static double getDouble(Config config, String path, double def) {
    if (config.hasPath(path)) {
      return config.getDouble(path);
    }
    return def;
  }

  /**
   * Return {@link Config} value at <code>path</code> if <code>config</code> has path. If not return <code>def</code>
   *
   * @param config in which the path may be present
   * @param path key to look for in the config object
   * @return config value at <code>path</code> if <code>config</code> has path. If not return <code>def</code>
   */
  public static Config getConfig(Config config, String path, Config def) {
    if (config.hasPath(path)) {
      return config.getConfig(path);
    }
    return def;
  }

  /**
   * <p>
   * An extension to {@link Config#getStringList(String)}. The value at <code>path</code> can either be a TypeSafe
   * {@link ConfigList} of strings in which case it delegates to {@link Config#getStringList(String)} or as list of
   * comma separated strings in which case it splits the comma separated list.
   *
   *
   * </p>
   * Additionally
   * <li>Returns an empty list if <code>path</code> does not exist
   * <li>removes any leading and lagging quotes from each string in the returned list.
   *
   * Examples below will all return a list [1,2,3] without quotes
   *
   * <ul>
   * <li> a.b=[1,2,3]
   * <li> a.b=["1","2","3"]
   * <li> a.b=1,2,3
   * <li> a.b="1","2","3"
   * </ul>
   *
   * @param config in which the path may be present
   * @param path key to look for in the config object
   * @return list of strings
   */
  public static List<String> getStringList(Config config, String path) {

    if (!config.hasPath(path)) {
      return Collections.emptyList();
    }

    List<String> valueList;
    try {
      valueList = config.getStringList(path);
    } catch (ConfigException.WrongType e) {
      if (StringUtils.isEmpty(config.getString(path))) {
        return Collections.emptyList();
      }
      /*
       * Using CSV Reader as values could be quoted.
       * E.g The string "a","false","b","10,12" will be split to a list of 4 elements and not 5.
       *
       * a
       * false
       * b
       * 10,12
       */
      try (CSVReader csvr = new CSVReader(new StringReader(config.getString(path)))) {
        valueList = Lists.newArrayList(csvr.readNext());
      } catch (IOException ioe) {
        throw new RuntimeException(ioe);
      }
    }

    // Remove any leading or lagging quotes in the values
    // [\"a\",\"b\"] ---> [a,b]
    return Lists.newArrayList(Lists.transform(valueList, new Function<String, String>() {
      @Override
      public String apply(String input) {
        if (input == null) {
          return input;
        }
        return input.replaceAll("^\"|\"$", "");
      }
    }));
  }

  /**
   * Check if the given <code>key</code> exists in <code>config</code> and it is not null or empty
   * Uses {@link StringUtils#isNotBlank(CharSequence)}
   * @param config which may have the key
   * @param key to look for in the config
   *
   * @return True if key exits and not null or empty. False otherwise
   */
  public static boolean hasNonEmptyPath(Config config, String key) {
    return config.hasPath(key) && StringUtils.isNotBlank(config.getString(key));
  }

  /**
   * Check that every key-value in superConfig is in subConfig
   */
  public static boolean verifySubset(Config superConfig, Config subConfig) {
    for (Map.Entry<String, ConfigValue> entry : subConfig.entrySet()) {
      if (!superConfig.hasPath(entry.getKey()) || !superConfig.getValue(entry.getKey()).unwrapped()
          .equals(entry.getValue().unwrapped())) {
        return false;
      }
    }
    return true;
  }

  /**
   * Resolves encrypted config value(s) by considering on the path with "encConfigPath" as encrypted.
   * (If encConfigPath is absent or encConfigPath does not exist in config, config will be just returned untouched.)
   * It will use Password manager via given config. Thus, convention of PasswordManager need to be followed in order to be decrypted.
   * Note that "encConfigPath" path will be removed from the config key, leaving child path on the config key.
   * e.g:
   *  encConfigPath = enc.conf
   *  - Before : { enc.conf.secret_key : ENC(rOF43721f0pZqAXg#63a) }
   *  - After  : { secret_key : decrypted_val }
   *
   * @param config
   * @param encConfigPath
   * @return
   */
  public static Config resolveEncrypted(Config config, Optional<String> encConfigPath) {
    if (!encConfigPath.isPresent() || !config.hasPath(encConfigPath.get())) {
      return config;
    }

    Config encryptedConfig = config.getConfig(encConfigPath.get());

    PasswordManager passwordManager = PasswordManager.getInstance(configToProperties(config));
    Map<String, String> tmpMap = Maps.newHashMapWithExpectedSize(encryptedConfig.entrySet().size());
    for (Map.Entry<String, ConfigValue> entry : encryptedConfig.entrySet()) {
      String val = entry.getValue().unwrapped().toString();
      val = passwordManager.readPassword(val);
      tmpMap.put(entry.getKey(), val);
    }
    return ConfigFactory.parseMap(tmpMap).withFallback(config);
  }
}
