// Copyright (C) 2022 Verisign, Inc.
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; either
// version 2.1 of the License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
// USA

package com.verisignlabs.dnssec.cl;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Properties;
import java.util.logging.Formatter;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;

import org.apache.commons.cli.AlreadySelectedException;
import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.CommandLineParser;
import org.apache.commons.cli.DefaultParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;
import org.apache.commons.cli.UnrecognizedOptionException;

import com.verisignlabs.dnssec.security.DnsKeyAlgorithm;

/**
 * This is a base class for jdnssec command line tools. Each command line tool
 * should inherit from this class, create a subclass of CLIStateBase (overriding
 * setupOptions and processOptions), and implement the execute() method.
 * Subclasses also have their own main() methods, which should just create the
 * subclass variant of the CLIState and call run().
 */
public abstract class CLBase {
  protected Logger log = Logger.getLogger(this.getClass().toString());
  protected Options opts;
  protected String name;
  protected String usageStr;
  protected Properties props;
  protected CommandLine cli;

  protected CLBase(String name, String usageStr) {
    this.name = name;
    this.usageStr = usageStr;

    setup();
  }

  /**
   * This is a very simple log formatter that simply outputs the log level and
   * log string.
   */
  public static class BareLogFormatter extends Formatter {

    public String format(LogRecord arg0) {
      StringBuilder out = new StringBuilder();
      String lvl = arg0.getLevel().getName();

      out.append(lvl);
      out.append(": ");
      out.append(arg0.getMessage());
      out.append("\n");

      return out.toString();
    }
  }

  /** This is the base set of command line options provided to all subclasses. */
  private void setupCommonOptions() {
    // Set up the standard set of options that all jdnssec command line tools will
    // implement.

    // boolean options
    opts.addOption("h", "help", false, "Print this message.");
    opts.addOption("m", "multiline", false,
        "Output DNS records using 'multiline' format");

    opts.addOption(Option.builder("l").longOpt("log-level").argName("level").hasArg()
        .desc("set the logging level with either java.util.logging levels, or 0-6").build());
    opts.addOption(Option.builder("v").longOpt("verbose").desc(
        "set as verbose (log-level = fine)").build());

    opts.addOption(Option.builder("c").longOpt("config").argName("file").hasArg()
        .desc("configuration file (format: java properties)").build());

    opts.addOption(Option.builder("A").hasArg().argName("alias:original:mnemonic").longOpt("alg-alias")
        .desc("Define an alias for an algorithm").build());
  }

  /**
   * This is an overridable method for subclasses to add their own command line
   * options.
   */
  protected abstract void setupOptions();

  /**
   * Initialize the command line options
   */
  public void setup() {
    opts = new Options();
    setupCommonOptions();
    setupOptions();
  }

  /**
   * This is the main method for parsing the command line arguments. Subclasses
   * generally override processOptions() rather than this method. This method
   * creates the parsing objects and processes the common options.
   * 
   * @param args The command line arguments.
   */
  public void parseCommandLine(String[] args) {
    String[] logLevelOptionKeys = { "log_level", "log-level" };
    String[] multilineOptionKeys = { "multiline" };
    CommandLineParser parser = new DefaultParser();

    try {
      cli = parser.parse(opts, args);
    } catch (UnrecognizedOptionException e) {
      fail("unknown option encountered: " + e.getMessage());
    } catch (AlreadySelectedException e) {
      fail("mutually exclusive options have been selected:\n     " + e.getMessage());
    } catch (ParseException e) {
      fail("unable to parse command line: " + e);
    }

    if (cli.hasOption('h')) {
      usage();
    }

    String loadedConfig = loadConfig(cli.getOptionValue('c'));

    Logger rootLogger = Logger.getLogger("");

    // we set log level with both --log-level and -v/--verbose.
    String logLevel = cliOption("log-level", logLevelOptionKeys, null);
    if (logLevel == null) {
      logLevel = cli.hasOption("v") ? "fine" : "warning";
    }
    setLogLevel(rootLogger, logLevel);

    for (Handler h : rootLogger.getHandlers()) {
      h.setLevel(rootLogger.getLevel());
      h.setFormatter(new BareLogFormatter());
    }

    if (loadedConfig != null) {
      log.info("Loaded config file: " + loadedConfig);
    }

    if (cliBooleanOption("m", multilineOptionKeys, false)) {
      org.xbill.DNS.Options.set("multiline");
    }

    processAliasOptions();

    processOptions();
  }

  /**
   * Process additional tool-specific options. Subclasses generally override
   * this.
   */
  protected abstract void processOptions();

  /**
   * Load a configuration (java properties) file for jdnssec-tools. Returns
   * the path of the loaded file.
   * 
   * @param configFile a given path to a config file. This will be considered
   *                   first.
   * @return The path of the file that was actually loaded, or null if no config
   *         file was loaded.
   */
  protected String loadConfig(String configFile) {
    // Do not load config files twice
    if (props != null) {
      return null;
    }
    props = new Properties();
    String[] configFiles = { configFile, "jdnssec-tools.properties", ".jdnssec-tools.properties",
        System.getProperty("user.home") + "/.jdnssec-tools.properties" };

    File f = null;

    for (String fname : configFiles) {
      if (fname == null) {
        continue;
      }
      f = new File(fname);
      if (!f.canRead()) {
        continue;
      }

      try (FileInputStream stream = new FileInputStream(f)) {
        props.load(stream);
        break; // load the first config file found in our list
      } catch (IOException e) {
        log.warning("Could not read config file " + f.getName() + ": " + e);
      }
    }

    if (f != null) {
      return f.getPath();
    }
    return null;
  }

  protected void fail(String errorMessage) {
    log.severe(errorMessage);
    System.exit(64);
  }
  /** Print out the usage and help statements, then quit. */
  public void usage() {
    HelpFormatter f = new HelpFormatter();

    PrintWriter out = new PrintWriter(System.err);

    // print our own usage statement:
    f.printHelp(out, 120, usageStr, null, opts, HelpFormatter.DEFAULT_LEFT_PAD,
        HelpFormatter.DEFAULT_DESC_PAD, null);

    out.flush();
    System.exit(0);

  }

  /**
   * Set the logging level based on a string value
   *
   * @param logger   The logger to set -- usually the rootLogger
   * @param levelStr A level string that is either an integer from 0 to 6, or a
   *                 java.util.logging log level string (severe, warning, info,
   *                 fine, finer,
   *                 finest).
   */
  private void setLogLevel(Logger logger, String levelStr) {
    Level level;
    int internalLogLevel = Utils.parseInt(levelStr, -1);
    if (internalLogLevel != -1) {
      switch (internalLogLevel) {
        case 0:
          level = Level.OFF;
          break;
        case 1:
          level = Level.SEVERE;
          break;
        case 2:
        default:
          level = Level.WARNING;
          break;
        case 3:
          level = Level.INFO;
          break;
        case 4:
          level = Level.FINE;
          break;
        case 5:
        case 6:
          level = Level.ALL;
      }
    } else {
      try {
        level = Level.parse(levelStr.toUpperCase());
      } catch (IllegalArgumentException e) {
        System.err.println("Verbosity level '" + levelStr + "' not recognized");
        level = Level.WARNING;
      }
    }
    logger.setLevel(level);
  }

  /**
   * Process both property file based alias definitions and command line alias
   * definitions
   */
  protected void processAliasOptions() {
    DnsKeyAlgorithm algs = DnsKeyAlgorithm.getInstance();
    // First parse any command line options
    // those look like '-A <alias-num>:<orig-num>:<mnemonic>', e.g., '-A
    // 21:13:ECDSAP256-NSEC6'
    String[] optstrs = null;
    if ((optstrs = cli.getOptionValues('A')) != null) {
      for (String value : optstrs) {
        String[] valueComponents = value.split(":");
        int aliasAlg = Utils.parseInt(valueComponents[0], -1);
        int origAlg = Utils.parseInt(valueComponents[1], -1);
        String mnemonic = valueComponents[2];

        if (mnemonic != null && origAlg >= 0 && aliasAlg >= 0) {
          algs.addAlias(aliasAlg, mnemonic, origAlg);
        }
      }
    }

    // Next see if we have any alias options in properties
    // Those look like 'signzone.alias.<alias-mnemonic> =
    // <orig-alg-num>:<alias-alg-num>'
    for (String key : props.stringPropertyNames()) {
      if (key.startsWith(name + ".alias.") || key.startsWith("alias.")) {
        String[] keyComponents = key.split("\\.");
        String mnemonic = keyComponents[keyComponents.length - 1];
        String[] valueComponents = props.getProperty(key).split(":");
        int origAlg = Utils.parseInt(valueComponents[0], -1);
        int aliasAlg = Utils.parseInt(valueComponents[1], -1);

        if (mnemonic != null && origAlg >= 0 && aliasAlg >= 0) {
          algs.addAlias(aliasAlg, mnemonic, origAlg);
        }
      }
    }
  }

  /**
   * Given a parsed command line, option, and list of possible config
   * properties, and a default value, determine value for the option
   *
   * @param option       The option name
   * @param properties   A list of configuration parameters that we would like
   *                     to use for this option, from most preferred to least.
   * @param defaultValue A default value to return if either the option or
   *                     config value cannot be parsed, or neither are present.
   * @return The found value, or the default value.
   */
  protected String cliOption(String option, String[] properties, String defaultValue) {
    if (cli.hasOption(option)) {
      return cli.getOptionValue(option);
    }
    for (String property : properties) {
      // first look up the scoped version of the property
      String value = props.getProperty(name + "." + property);
      if (value != null) {
        return value;
      }
      value = props.getProperty(property);
      if (value != null) {
        return value;
      }
    }
    return defaultValue;
  }

  /**
   * Given a parsed command line, option, and list of possible config
   * properties, determine the value for the option, converting the value to
   * long.
   */
  protected long cliLongOption(String option, String[] properties, long defaultValue) {
    String value = cliOption(option, properties, Long.toString(defaultValue));
    return Utils.parseLong(value, defaultValue);
  }

  /**
   * Given a parsed command line, option, and list of possible config
   * properties, determine the value for the option, converting the value to
   * int.
   */
  protected int cliIntOption(String option, String[] properties, int defaultValue) {
    String value = cliOption(option, properties, Integer.toString(defaultValue));
    return Utils.parseInt(value, defaultValue);
  }

  /**
   * Given a parsed command line, option, and list of possible config
   * properties, determine the value for the option, converting the value to
   * a boolean.
   */
  protected boolean cliBooleanOption(String option, String[] properties, boolean defaultValue) {
    if (cli.hasOption(option)) {
      return true;
    }
    String value = cliOption(option, properties, Boolean.toString(defaultValue));
    return Boolean.parseBoolean(value);
  }

  public abstract void execute() throws Exception;

  public void run(String[] args) {

    parseCommandLine(args);
    log = Logger.getLogger(this.getClass().toString());

    try {
      execute();
    } catch (Exception e) {
      e.printStackTrace();
      System.exit(1);
    }
  }
}
