package denominator.cli;

import com.google.common.base.CaseFormat;
import com.google.common.base.Charsets;
import com.google.common.base.Function;
import com.google.common.base.Functions;
import com.google.common.base.Joiner;
import com.google.common.base.Predicate;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableList.Builder;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Ordering;
import com.google.common.io.Files;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.InstanceCreator;
import com.google.gson.TypeAdapter;
import com.google.gson.internal.ConstructorConstructor;
import com.google.gson.internal.bind.MapTypeAdapterFactory;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import dagger.ObjectGraph;
import dagger.Provides;
import denominator.Credentials;
import denominator.Credentials.AnonymousCredentials;
import denominator.Credentials.ListCredentials;
import denominator.Credentials.MapCredentials;
import denominator.DNSApiManager;
import denominator.Denominator.Version;
import denominator.Provider;
import denominator.Providers;
import denominator.cli.GeoResourceRecordSetCommands.GeoRegionList;
import denominator.cli.GeoResourceRecordSetCommands.GeoResourceRecordAddRegions;
import denominator.cli.GeoResourceRecordSetCommands.GeoResourceRecordSetApplyTTL;
import denominator.cli.GeoResourceRecordSetCommands.GeoResourceRecordSetGet;
import denominator.cli.GeoResourceRecordSetCommands.GeoResourceRecordSetList;
import denominator.cli.GeoResourceRecordSetCommands.GeoTypeList;
import denominator.cli.ResourceRecordSetCommands.ResourceRecordSetAdd;
import denominator.cli.ResourceRecordSetCommands.ResourceRecordSetApplyTTL;
import denominator.cli.ResourceRecordSetCommands.ResourceRecordSetDelete;
import denominator.cli.ResourceRecordSetCommands.ResourceRecordSetGet;
import denominator.cli.ResourceRecordSetCommands.ResourceRecordSetList;
import denominator.cli.ResourceRecordSetCommands.ResourceRecordSetRemove;
import denominator.cli.ResourceRecordSetCommands.ResourceRecordSetReplace;
import denominator.cli.ZoneCommands.ZoneAdd;
import denominator.cli.ZoneCommands.ZoneDelete;
import denominator.cli.ZoneCommands.ZoneList;
import denominator.cli.ZoneCommands.ZoneUpdate;
import denominator.dynect.DynECTProvider;
import denominator.model.Zone;
import denominator.ultradns.UltraDNSProvider;
import feign.Logger;
import feign.Logger.Level;
import io.airlift.airline.Cli;
import io.airlift.airline.Cli.CliBuilder;
import io.airlift.airline.Command;
import io.airlift.airline.Help;
import io.airlift.airline.Option;
import io.airlift.airline.OptionType;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.jce.provider.X509CertParser;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.yaml.snakeyaml.Yaml;

import javax.inject.Singleton;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.lang.reflect.Type;
import java.net.MalformedURLException;
import java.net.URL;
import java.security.KeyFactory;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import static com.google.common.base.Preconditions.checkArgument;
import static denominator.CredentialsConfiguration.credentials;
import static java.lang.String.format;

public class Denominator {

  static final TypeToken<Map<String, Object>> token = new TypeToken<Map<String, Object>>() {
  };
  static final TypeAdapter<Map<String, Object>>
      doubleToInt =
      new TypeAdapter<Map<String, Object>>() {
        TypeAdapter<Map<String, Object>>
            delegate =
            new MapTypeAdapterFactory(new ConstructorConstructor(
                Collections.<Type, InstanceCreator<?>>emptyMap()), false).create(new Gson(), token);

        @Override
        public void write(JsonWriter out, Map<String, Object> value) throws IOException {
          delegate.write(out, value);
        }

        @Override
        public Map<String, Object> read(JsonReader in) throws IOException {
          Map<String, Object> map = delegate.read(in);
          for (Entry<String, Object> entry : map.entrySet()) {
            if (entry.getValue() instanceof Double) {
              entry.setValue(Double.class.cast(entry.getValue()).intValue());
            }
          }
          return map;
        }
      }.nullSafe();
  // deals with scenario where gson Object type treats all numbers as doubles.
  static final Gson
      json =
      new GsonBuilder().registerTypeAdapter(token.getType(), doubleToInt).create();

  public static void main(String[] args) {
    CliBuilder<Runnable> builder = Cli.<Runnable>builder("denominator")
        .withDescription("Denominator: Portable control of DNS clouds")
        .withDefaultCommand(Help.class)
        .withCommand(Help.class)
        .withCommand(PrintVersion.class)
        .withCommand(ListProviders.class);

    builder.withGroup("zone")
        .withDescription("manage zones")
        .withDefaultCommand(ZoneList.class)
        .withCommand(ZoneList.class)
        .withCommand(ZoneAdd.class)
        .withCommand(ZoneUpdate.class)
        .withCommand(ZoneDelete.class);

    builder.withGroup("record")
        .withDescription("manage resource record sets in a zone")
        .withDefaultCommand(ResourceRecordSetList.class)
        .withCommand(ResourceRecordSetList.class)
        .withCommand(ResourceRecordSetGet.class)
        .withCommand(ResourceRecordSetAdd.class)
        .withCommand(ResourceRecordSetApplyTTL.class)
        .withCommand(ResourceRecordSetReplace.class)
        .withCommand(ResourceRecordSetRemove.class)
        .withCommand(ResourceRecordSetDelete.class);

    builder.withGroup("geo")
        .withDescription("manage geo resource record sets in a zone")
        .withDefaultCommand(GeoResourceRecordSetList.class)
        .withCommand(GeoTypeList.class)
        .withCommand(GeoRegionList.class)
        .withCommand(GeoResourceRecordSetList.class)
        .withCommand(GeoResourceRecordSetGet.class)
        .withCommand(GeoResourceRecordSetApplyTTL.class)
        .withCommand(GeoResourceRecordAddRegions.class);

    Cli<Runnable> denominatorParser = builder.build();
    try {
      denominatorParser.parse(args).run();
    } catch (RuntimeException e) {
      if (e instanceof NullPointerException) {
        e.printStackTrace();
      }
      System.err.println(";; error: " + e.getMessage());
      System.exit(1);
    }
    System.exit(0);
  }

  /**
   * Returns a log configuration module or null if none is needed.
   */
  static Object logModule(boolean quiet, boolean verbose) {
    checkArgument(!(quiet && verbose), "quiet and verbose flags cannot be used at the same time!");
    Logger.Level logLevel;
    if (quiet) {
      return null;
    } else if (verbose) {
      logLevel = Logger.Level.FULL;
    } else {
      logLevel = Logger.Level.BASIC;
    }
    return new LogModule(logLevel);
  }

  static String id(DNSApiManager mgr, String zoneIdOrName) {
    if (zoneIdOrName.indexOf('.') == -1) { // Assume that ids don't have dots in them!
      return zoneIdOrName;
    }
    if (zoneNameIsId(mgr.provider())) {
      return zoneIdOrName;
    }
    Iterator<Zone> result = mgr.api().zones().iterateByName(zoneIdOrName);
    checkArgument(result.hasNext(), "zone %s not found", zoneIdOrName);
    return result.next().id();
  }

  // Special-case providers known to use zone names as ids, as this usually saves 1-200ms of
  // lookups. We can later introduce a flag or other means to help third-party providers.
  static boolean zoneNameIsId(Provider provider) {
    return provider instanceof UltraDNSProvider || provider instanceof DynECTProvider;
  }

  @Command(name = "version", description = "output the version of denominator and java runtime in use")
  public static class PrintVersion implements Runnable {

    public void run() {
      System.out.println("Denominator " + Version.INSTANCE);
      System.out.println("Java version: " + System.getProperty("java.version"));
    }
  }

  @Command(name = "providers", description = "List the providers and their metadata ")
  public static class ListProviders implements Runnable {

    final static String table = "%-10s %-51s %-14s %-14s %s%n";

    public static String providerAndCredentialsTable() {
      StringBuilder builder = new StringBuilder();

      builder.append(format(
          table, "provider", "url", "duplicateZones", "credentialType", "credentialArgs"));
      for (Provider p : ImmutableSortedSet.copyOf(Ordering.usingToString(), Providers.list())) {
        if (p.credentialTypeToParameterNames().isEmpty()) {
          builder.append(
              format("%-10s %-51s %-14s %n", p.name(), p.url(), p.supportsDuplicateZoneNames()));
        }
        for (Entry<String, Collection<String>> e : p.credentialTypeToParameterNames().entrySet()) {
          String params = Joiner.on(' ').join(e.getValue());
          builder.append(format(
              table, p.name(), p.url(), p.supportsDuplicateZoneNames(), e.getKey(), params));
        }
      }
      return builder.toString();
    }

    public void run() {
      System.out.println(providerAndCredentialsTable());
    }
  }

  public static abstract class DenominatorCommand implements Runnable {

    private static final String ENV_PREFIX = "DENOMINATOR_";
    @Option(type = OptionType.GLOBAL, name = {"-q",
                                              "--quiet"}, description = "do not emit informational messages about http commands invoked")
    public boolean quiet;
    @Option(type = OptionType.GLOBAL, name = {"-v",
                                              "--verbose"}, description = "emit details such as http requests sent and responses received")
    public boolean verbose;
    @Option(type = OptionType.GLOBAL, name = {"-p",
                                              "--provider"}, description = "provider to affect")
    public String providerName;
    @Option(type = OptionType.GLOBAL, name = {"-u",
                                              "--url"}, description = "alternative api url to connect to")
    public String url;
    @Option(type = OptionType.GLOBAL, name = {"-c",
                                              "--credential"}, description = "adds a credential argument (execute denominator providers for what these are)")
    public List<String> credentialArgs;
    @Option(type = OptionType.GLOBAL, name = {"-C",
                                              "--config"}, description = "path to configuration file (used to store credentials). default: ~/.denominatorconfig")
    public String configPath = "~/.denominatorconfig";
    @Option(type = OptionType.GLOBAL, name = {"-n",
                                              "--configuration-name"}, description = "unique name of provider configuration")
    public String providerConfigurationName;
    protected Credentials credentials = AnonymousCredentials.INSTANCE;

    @SuppressWarnings("unchecked")
    public void run() {
      setProxyFromEnv();

      if (providerName != null && credentialArgs != null) {
        credentials = ListCredentials.from(Lists.transform(credentialArgs, decodeAnyPems));
      } else if (providerConfigurationName != null) {
        Map<?, ?> configFromFile = getConfigFromFile();
        if (configFromFile != null) {
          credentials = MapCredentials.from(
              Maps.transformValues(Map.class.cast(configFromFile.get("credentials")), decodeAnyPems));
          providerName = configFromFile.get("provider").toString();
          if (configFromFile.containsKey("url")) {
            url = configFromFile.get("url").toString();
          }
        }
      } else {
        overrideFromEnv(System.getenv());
      }
      Provider provider = Providers.getByName(providerName);
      if (url != null) {
        provider = Providers.withUrl(provider, url);
      }

      Builder<Object> modulesForGraph = ImmutableList.builder() //
          .add(Providers.provide(provider)) //
          .add(Providers.instantiateModule(provider));

      Object logModule = logModule(quiet, verbose);
      if (logModule != null) {
        modulesForGraph.add(logModule);
      }

      if (credentials != AnonymousCredentials.INSTANCE) {
        modulesForGraph.add(credentials(credentials));
      }
      DNSApiManager mgr = null;
      try {
        mgr = ObjectGraph.create(modulesForGraph.build().toArray()).get(DNSApiManager.class);
        for (Iterator<String> i = doRun(mgr); i.hasNext(); ) {
          System.out.println(i.next());
        }
      } finally {
        if (mgr != null) {
          try {
            mgr.close();
          } catch (IOException ignored) {

          }
        }
      }
    }

    private static final Function<Object, Object> maybeDecodeX509Pem = new Function<Object, Object>() {
      @Override
      public Object apply(Object input) {
        if (input instanceof String && input.toString().contains("BEGIN CERTIFICATE")) {
          try {
            X509CertParser x509CertParser = new X509CertParser();
            x509CertParser.engineInit(new ByteArrayInputStream(input.toString().getBytes()));
            return x509CertParser.engineRead();
          } catch (Exception ex) {
            return input;
          }
        }
        return input;
      }
    };

    private static final Function<Object, Object> maybeDecodePrivateKeyPem = new Function<Object, Object>() {
      @Override
      public Object apply(Object input) {
        if (input instanceof String && input.toString().contains("BEGIN RSA PRIVATE KEY")) {
          try {
            PEMKeyPair pemKeyPair = (PEMKeyPair) new PEMParser(new StringReader(input.toString())).readObject();
            PrivateKeyInfo privateKeyInfo = pemKeyPair.getPrivateKeyInfo();
            KeyFactory keyFact = KeyFactory.getInstance(
                privateKeyInfo.getPrivateKeyAlgorithm().getAlgorithm().getId(), new BouncyCastleProvider());
            return keyFact.generatePrivate(new PKCS8EncodedKeySpec(privateKeyInfo.getEncoded()));
          } catch (Exception ex) {
            return input;
          }
        }
        return input;
      }
    };

    private static final Function<Object, Object> decodeAnyPems =
        Functions.compose(maybeDecodeX509Pem, maybeDecodePrivateKeyPem);

    /**
     * Load configuration for given providerConfigurationName from a YAML configuration file.
     */
    Map<?, ?> getConfigFromFile() {
      if (configPath == null) {
        return null;
      }
      String configFileContent = null;
      try {
        configFileContent = getFileContentsFromPath(configPath);
      } catch (IOException e) {
        System.err.println("configuration file not found: " + e.getMessage());
        System.exit(1);
      }
      return getConfigFromYaml(configFileContent);
    }

    Map<?, ?> getConfigFromYaml(String yamlAsString) {
      Yaml yaml = new Yaml();
      Iterable<Object> configs = yaml.loadAll(yamlAsString);
      Object providerConf = FluentIterable.from(configs).firstMatch(new Predicate<Object>() {
        @Override
        public boolean apply(Object input) {
          return providerConfigurationName.equals(Map.class.cast(input).get("name"));
        }
      }).get();
      return Map.class.cast(providerConf);
    }

    String getFileContentsFromPath(String path) throws IOException {
      if (path.startsWith("~")) {
        path = System.getProperty("user.home") + path.substring(1);
      }
      return Files.toString(new File(path), Charsets.UTF_8);
    }

    void overrideFromEnv(Map<String, String> env) {
      if (providerName == null) {
        providerName = env.get(ENV_PREFIX + "PROVIDER");
      }
      if (url == null) {
        url = env.get(ENV_PREFIX + "URL");
      }

      Provider providerLoaded = Providers.getByName(providerName);
      if (providerLoaded != null) {
        Map<String, String> credentialMap = new LinkedHashMap<String, String>();
        // merge the list of possible credentials
        for (Entry<String, Collection<String>> entry :
            providerLoaded.credentialTypeToParameterNames().entrySet()) {
          for (String paramName : entry.getValue()) {
            String
                upperParamName =
                CaseFormat.LOWER_CAMEL.to(CaseFormat.UPPER_UNDERSCORE, paramName);
            String value = env.get(ENV_PREFIX + upperParamName);
            if (value != null) {
              credentialMap.put(paramName, value);
            }
          }
        }
        if (!credentialMap.isEmpty()) {
          credentials = MapCredentials.from(credentialMap);
        }
      }
    }

    static void setProxyFromEnv() {
      setProtocolProxyFromEnv("http", System.getenv("HTTP_PROXY"));
      setProtocolProxyFromEnv("https", System.getenv("HTTPS_PROXY"));
    }

    static void setProtocolProxyFromEnv(String proto, String envProxy) {
      if (envProxy != null && !envProxy.isEmpty()) {
        try {
          URL proxyUrl = new URL(envProxy);

          String proxyHost = System.getProperty(proto + ".proxyHost");
          if ((proxyHost == null || proxyHost.isEmpty())) {
            System.setProperty(proto + ".proxyHost", proxyUrl.getHost());
            System.setProperty(proto + ".proxyPort",
                Integer.toString(
                    proxyUrl.getPort() == -1 ? proxyUrl.getDefaultPort() : proxyUrl.getPort()));
          }
        } catch (MalformedURLException e) {
          System.err.println("invalid " + proto + " proxy configuration: " + e.getMessage());
          System.exit(1);
        }
      }
    }

    /**
     * return a lazy iterator where possible to improve the perceived responsiveness of the cli
     */
    protected abstract Iterator<String> doRun(DNSApiManager mgr);
  }

  @dagger.Module(overrides = true, library = true)
  static class LogModule {

    final Logger.Level logLevel;

    LogModule(Level logLevel) {
      this.logLevel = logLevel;
    }

    @Provides
    @Singleton
    Logger logger() {
      return new Logger.ErrorLogger();
    }

    @Provides
    @Singleton
    Logger.Level level() {
      return logLevel;
    }
  }
}