/*
 * Copyright 2018 ConsenSys AG.
 *
 * Licensed 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 net.consensys.cava.config;

import static java.util.Objects.requireNonNull;

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import javax.annotation.Nullable;

import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ListMultimap;

/**
 * A schema for a configuration, providing default values and validation rules.
 */
public final class Schema {

  static final Schema EMPTY =
      new Schema(Collections.emptyMap(), Collections.emptyMap(), ArrayListMultimap.create(), Collections.emptyList());

  private final Map<String, String> propertyDescriptions;
  private final Map<String, Object> propertyDefaults;
  private final ListMultimap<String, PropertyValidator<Object>> propertyValidators;
  private final List<ConfigurationValidator> configurationValidators;

  Schema(
      Map<String, String> propertyDescriptions,
      Map<String, Object> propertyDefaults,
      ListMultimap<String, PropertyValidator<Object>> propertyValidators,
      List<ConfigurationValidator> configurationValidators) {
    this.propertyDescriptions = propertyDescriptions;
    this.propertyDefaults = propertyDefaults;
    this.propertyValidators = propertyValidators;
    this.configurationValidators = configurationValidators;
  }

  /**
   * The keys of all defaults provided by this schema.
   *
   * @return The keys for all defaults provided by this schema.
   */
  public Set<String> defaultsKeySet() {
    return propertyDefaults.keySet();
  }

  /**
   * Get the description for a key.
   *
   * @param key A configuration key (e.g. {@code "server.address.hostname"}).
   * @return A description associated with the key, or null if no description is available.
   */
  @Nullable
  public String description(String key) {
    requireNonNull(key);
    return propertyDescriptions.get(key);
  }

  /**
   * Check if a key has a default provided by this schema.
   *
   * @param key A configuration key (e.g. {@code "server.address.hostname"}).
   * @return {@code true} if this schema provides a default value for the key.
   */
  public boolean hasDefault(String key) {
    requireNonNull(key);
    return propertyDefaults.containsKey(key);
  }

  /**
   * Get a default value from this configuration.
   *
   * @param key A configuration key (e.g. {@code "server.address.hostname"}).
   * @return The value, or null if no default was available.
   */
  public Object getDefault(String key) {
    requireNonNull(key);
    return propertyDefaults.get(key);
  }

  /**
   * Get a default value from this configuration as a string.
   *
   * @param key A configuration key (e.g. {@code "server.address.hostname"}).
   * @return The value, or null if no default was available.
   * @throws InvalidConfigurationPropertyTypeException If the default value is not a string.
   */
  @Nullable
  public String getDefaultString(String key) {
    requireNonNull(key);
    return getTypedDefault(key, String.class, "string");
  }

  /**
   * Get a default value from this configuration as a integer.
   *
   * @param key A configuration key (e.g. {@code "server.address.port"}).
   * @return The value, or null if no default was available.
   * @throws InvalidConfigurationPropertyTypeException If the default value is not an integer.
   */
  @Nullable
  public Integer getDefaultInteger(String key) {
    requireNonNull(key);
    Object obj = propertyDefaults.get(key);
    if (obj == null) {
      return null;
    }
    if (obj instanceof Integer) {
      return (Integer) obj;
    }
    if (obj instanceof Long) {
      Long longValue = (Long) obj;
      if (longValue > Integer.MAX_VALUE) {
        throw new InvalidConfigurationPropertyTypeException(
            null,
            "Value of property '" + key + "' is too large for an integer");
      }
      return longValue.intValue();
    }
    throw new InvalidConfigurationPropertyTypeException(null, "Property at '" + key + "' was not an integer");
  }

  /**
   * Get a default value from this configuration as a long.
   *
   * @param key A configuration key (e.g. {@code "server.address.port"}).
   * @return The value, or null if no default was available.
   * @throws InvalidConfigurationPropertyTypeException If the default value is not a long.
   */
  @Nullable
  public Long getDefaultLong(String key) {
    requireNonNull(key);
    Object obj = propertyDefaults.get(key);
    if (obj == null) {
      return null;
    }
    if (obj instanceof Long) {
      return (Long) obj;
    }
    if (obj instanceof Integer) {
      return ((Integer) obj).longValue();
    }
    throw new InvalidConfigurationPropertyTypeException(null, "Property at '" + key + "' was not a long");
  }

  /**
   * Get a default value from this configuration as a double.
   *
   * @param key A configuration key (e.g. {@code "server.priority"}).
   * @return The value, or null if no default was available.
   * @throws InvalidConfigurationPropertyTypeException If the default value is not a double.
   */
  @Nullable
  public Double getDefaultDouble(String key) {
    requireNonNull(key);
    return getTypedDefault(key, Double.class, "double");
  }

  /**
   * Get a default value from this configuration as a boolean.
   *
   * @param key A configuration key (e.g. {@code "server.active"}).
   * @return The value, or null if no default was available.
   * @throws InvalidConfigurationPropertyTypeException If the default value is not a boolean.
   */
  @Nullable
  public Boolean getDefaultBoolean(String key) {
    requireNonNull(key);
    return getTypedDefault(key, Boolean.class, "boolean");
  }

  /**
   * Get a default value from this configuration as a map.
   *
   * @param key A configuration key (e.g. {@code "server.active"}).
   * @return The value, or null if no default was available.
   * @throws InvalidConfigurationPropertyTypeException If the default value is not a map.
   */
  @SuppressWarnings("unchecked")
  @Nullable
  public Map<String, Object> getDefaultMap(String key) {
    requireNonNull(key);
    return getTypedDefault(key, Map.class, "map");
  }

  /**
   * Get a default value from this configuration as a list.
   *
   * @param key A configuration key (e.g. {@code "server.common_names"}).
   * @return The value, or null if no default was available.
   * @throws IllegalArgumentException If the key cannot be parsed.
   * @throws InvalidConfigurationPropertyTypeException If the default value is not a list.
   */
  @SuppressWarnings("unchecked")
  @Nullable
  public List<Object> getDefaultList(String key) {
    requireNonNull(key);
    Object obj = propertyDefaults.get(key);
    if (obj == null) {
      return null;
    }
    if (obj instanceof List) {
      return (List<Object>) obj;
    }
    throw new InvalidConfigurationPropertyTypeException(null, "Property at '" + key + "' is not a list");
  }

  /**
   * Get a default value from this configuration as a list of strings.
   *
   * @param key A configuration key (e.g. {@code "server.common_names"}).
   * @return The value, or null if no default was available.
   * @throws InvalidConfigurationPropertyTypeException If the default value is not a list of strings.
   */
  @Nullable
  public List<String> getDefaultListOfString(String key) {
    requireNonNull(key);
    return getListDefault(key, String.class, "strings");
  }

  /**
   * Get a default value from this configuration as a list of integers.
   *
   * @param key A configuration key (e.g. {@code "server.address.ports"}).
   * @return The value, or null if no default was available.
   * @throws InvalidConfigurationPropertyTypeException If the default value is not a list of integers.
   */
  @SuppressWarnings("unchecked")
  @Nullable
  public List<Integer> getDefaultListOfInteger(String key) {
    requireNonNull(key);
    Object obj = propertyDefaults.get(key);
    if (obj == null) {
      return null;
    }
    if (obj instanceof List) {
      List<?> list = (List<?>) obj;
      if (list.isEmpty() || list.get(0) instanceof Integer) {
        return (List<Integer>) list;
      }
      if (list.get(0) instanceof Long) {
        return IntStream.range(0, list.size()).mapToObj(i -> {
          Long longValue = (Long) list.get(i);
          if (longValue == null) {
            return null;
          }
          if (longValue > Integer.MAX_VALUE) {
            throw new InvalidConfigurationPropertyTypeException(
                null,
                "Value of property '" + key + "', index " + i + ", is too large for an integer");
          }
          return longValue.intValue();
        }).collect(Collectors.toList());
      }
    }
    throw new InvalidConfigurationPropertyTypeException(null, "Property at '" + key + "' was not a list of integers");
  }

  /**
   * Get a default value from this configuration as a list of longs.
   *
   * @param key A configuration key (e.g. {@code "server.address.ports"}).
   * @return The value, or null if no default was available.
   * @throws InvalidConfigurationPropertyTypeException If the default value is not a list of longs.
   */
  @SuppressWarnings("unchecked")
  @Nullable
  public List<Long> getDefaultListOfLong(String key) {
    requireNonNull(key);
    Object obj = propertyDefaults.get(key);
    if (obj == null) {
      return null;
    }
    if (obj instanceof List) {
      List<?> list = (List<?>) obj;
      if (list.isEmpty() || list.get(0) instanceof Long) {
        return (List<Long>) list;
      }
      if (list.get(0) instanceof Integer) {
        return ((List<Integer>) list).stream().map(i -> {
          if (i == null) {
            return null;
          }
          return i.longValue();
        }).collect(Collectors.toList());
      }
    }
    throw new InvalidConfigurationPropertyTypeException(null, "Property at '" + key + "' was not a list of longs");
  }

  /**
   * Get a default value from this configuration as a list of doubles.
   *
   * @param key A configuration key (e.g. {@code "server.priorities"}).
   * @return The value, or null if no default was available.
   * @throws InvalidConfigurationPropertyTypeException If the default value is not a list of doubles.
   */
  @Nullable
  public List<Double> getDefaultListOfDouble(String key) {
    requireNonNull(key);
    return getListDefault(key, Double.class, "doubles");
  }

  /**
   * Get a default value from this configuration as a list of booleans.
   *
   * @param key A configuration key (e.g. {@code "server.flags"}).
   * @return The value, or null if no default was available.
   * @throws InvalidConfigurationPropertyTypeException If the default value is not a list of booleans.
   */
  @Nullable
  public List<Boolean> getDefaultListOfBoolean(String key) {
    requireNonNull(key);
    return getListDefault(key, Boolean.class, "booleans");
  }

  /**
   * Get a default value from this configuration as a list of maps.
   *
   * @param key A configuration key (e.g. {@code "mainnet.servers"}).
   * @return The value, or null if no default was available.
   * @throws InvalidConfigurationPropertyTypeException If the default value is not a list of maps.
   */
  @Nullable
  public List<Map<String, Object>> getDefaultListOfMap(String key) {
    requireNonNull(key);
    return getListDefault(key, Map.class, "maps");
  }

  @Nullable
  private <T> T getTypedDefault(String key, Class<T> clazz, String typeName) {
    Object obj = propertyDefaults.get(key);
    if (obj == null) {
      return null;
    }
    if (clazz.isInstance(obj)) {
      return clazz.cast(obj);
    }
    throw new InvalidConfigurationPropertyTypeException(null, "Property at '" + key + "' was not a " + typeName);
  }

  @SuppressWarnings("unchecked")
  @Nullable
  private <T> List<T> getListDefault(String key, Class<?> listType, String typeName) {
    Object obj = propertyDefaults.get(key);
    if (obj == null) {
      return null;
    }
    if (obj instanceof List) {
      List<?> list = (List<?>) obj;
      if (list.isEmpty() || listType.isInstance(list.get(0))) {
        return (List<T>) list;
      }
    }
    throw new InvalidConfigurationPropertyTypeException(
        null,
        "Property at '" + key + "' was not a list of " + typeName);
  }

  /**
   * Validate a configuration against this schema.
   *
   * <p>
   * The validations are done incrementally as the stream is consumed. Use {@code .limit(...)} on the stream to control
   * the maximum number of validation errors to receive.
   *
   * @param configuration The configuration to validate.
   * @return A stream containing any errors encountered during validation.
   */
  public Stream<ConfigurationError> validate(Configuration configuration) {
    requireNonNull(configuration);

    Stream<ConfigurationError> propertyErrors = propertyValidators.entries().stream().flatMap(e -> {
      String key = e.getKey();
      PropertyValidator<Object> validator = e.getValue();
      Object value = configuration.get(key);
      DocumentPosition position = configuration.inputPositionOf(key);
      List<ConfigurationError> errors = validator.validate(key, position, value);
      if (errors == null) {
        return Stream.empty();
      }
      return errors.stream();
    });

    Stream<ConfigurationError> configErrors = configurationValidators.stream().flatMap(v -> {
      List<ConfigurationError> errors = v.validate(configuration);
      if (errors == null) {
        return Stream.empty();
      }
      return errors.stream();
    });

    return Stream.concat(propertyErrors, configErrors);
  }
}
