package net.zortrium.util;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Map;

import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;

/**
 * General utility class to handle command-line options. Keeps track of
 * available options and values assigned to specific options. Treats options as
 * the appropriate object types rather than only {@code String} objects, but is
 * not restricted to a set of built-in types. Error messages and help strings
 * are provided with an absolute minimum amount of extra configuration (in many
 * cases, none at all).
 * <p>
 * Applications using this class should first add all needed options via
 * {@link #add(Class, String)}, immediately applying any needed properties to
 * the new options. Optionally, a usage string may be specified with
 * {@link #setUsageString(String)}. Afterwards, the application should pass the
 * command-line argument array into {@link #readArguments(String[])}. If any
 * given values are invalid, required values are missing, or any other problems
 * while reading arguments, an error message is printed and the program exits.
 * If arguments are parsed successfully, option values may then be accessed
 * using {@link #get(Class, String)}. At any time after options have been added,
 * the help message may be manually printed using {@link #printHelp(String)}.
 * <p>
 * This code may be used, modified, and redistributed provided that the author
 * tag below remains intact. I do ask that if you have general-purpose
 * suggestions, please send me a note so I can consider incorporating them into
 * this class.
 * @param <T> Type of value held by the option.
 * @author Sean Barker (zortrium@gmail.com)
 */
public class Options<T> {

  /**
   * Interface for converting input {@code String} objects to {@code T} objects.
   * Also allows for validation prior to parsing. If not using the default
   * parser, pass a class implementing this interface to
   * {@link Options#setParser(Parser)}. Note that you CANNOT rely on other
   * options having been already initialized during parsing.
   * @param <T> The type of option to parse.
   */
  public interface Parser<T> {

    /**
     * Reads an option object from the input string specified by the user on the
     * command line. Throws a {@code FailedParseException} if the input cannot
     * be properly parsed.
     * @param inputString The option input string from the command line.
     * @return The parsed input string.
     * @throws FailedParseException If the parse cannot be completed.
     */
    public T readInput(String inputString) throws FailedParseException;

  }

  /**
   * Exception indicating an input that could not be successfully parsed into
   * the option's object type and providing a descriptive error message.
   */
  public static class FailedParseException extends Exception {

    /**
     * Serialization constant.
     */
    private static final long serialVersionUID = 1L;

    /**
     * The bad option input value.
     */
    private final String badInput;

    /**
     * Create a new exception for a bad option input with the specified error.
     * @param badInput The bad option input.
     * @param errorMessage A descriptive error message.
     */
    public FailedParseException(String badInput, String errorMessage) {
      super(errorMessage);
      this.badInput = badInput;
    }

  }

  /**
   * Interface for custom option validators. By default, no validation occurs --
   * pass a class implementing this interface to
   * {@link Options#setValidator(Validator)} to provide validation behavior.
   * Note that all validation occurs after all parsing has occurred: thus, you
   * may call {@link Options#get(Class, String)} from within any option's
   * validator to check on any other option's parsed (but unvalidated) value.
   * This allows you to, for example, have mutually exclusive options.
   * @param <T> The type of option to validate.
   */
  public interface Validator<T> {

    /**
     * Perform any validation of the input after it's passed to the parser.
     * Returns {@code null} if the input passes validation and an error message
     * otherwise.
     * @param parsedValue The parsed option value.
     * @return {@code Null} if all conditions are met and an error message
     *         otherwise.
     */
    public String check(T parsedValue);

  }

  /**
   * The default parser that uses the class' String constructor to parse the
   * input string.
   */
  private class DefaultParser implements Parser<T> {

    /**
     * The class' String constructor.
     */
    private Constructor<T> constructor;

    /**
     * Verify that the given class has the appropriate constructor.
     */
    private DefaultParser() {
      try {
        this.constructor = clazz.getConstructor(String.class);
      } catch (SecurityException e) {
        throw new RuntimeException(e);
      } catch (NoSuchMethodException e) {
        throw new RuntimeException("Bad option type: " + clazz + " has no String constructor: "
            + "you probably need to specify a custom parser with setParser()");
      }
    }

    /**
     * Use the default constructor to parse the string.
     */
    @Override
    public T readInput(String inputString) throws FailedParseException {
      try {
        return constructor.newInstance(inputString);
      } catch (InvocationTargetException e) {
        throw new FailedParseException(inputString, "illegal input format");
      } catch (InstantiationException e) {
        throw new RuntimeException(e);
      } catch (IllegalAccessException e) {
        throw new RuntimeException(e);
      }
    }
  }

  /**
   * Exception indicating that an illegal value was assigned to an option.
   */
  private static class BadOptionValueException extends Exception {

    /**
     * Serialization constant.
     */
    private static final long serialVersionUID = 1L;

    /**
     * Create a new exception for an illegal or missing required option value.
     * @param option The option with the bad value.
     * @param badValue The illegal value (may be null).
     * @param msg An appropriate error message.
     */
    private BadOptionValueException(Options<?> option, String badValue, String msg) {
      super("Bad option value '" + badValue + "' for option '" + option.name + "': " + msg);
    }

  }

  /**
   * The command specifying how to run the program.
   */
  private static String usageCmd;

  /**
   * Map of all options referenced by full names.
   */
  private static final Map<String, Options<?>> options = Maps.newHashMap();

  /**
   * Map of all options referenced by short names.
   */
  private static final Map<Character, Options<?>> shortOptions = Maps.newHashMap();

  /**
   * Array of all options in the order they were added, to preserve the ordering
   * in the help string.
   */
  private static ArrayList<Options<?>> orderedOptions = Lists.newArrayList();

  static {
    addStandardOptions();
  }

  /**
   * Read in command line arguments. This should be called once from the
   * application's main method (passing the command-line argument array,
   * assuming all arguments are program flags) after all options have been
   * initialized with {@link #add(Class, String)}. After being called, the
   * option values may be queried using {@link #get(Class, String)}. Prints an
   * error message and exits if an invalid option is encountered, or if a
   * required option is not specified.
   * @param args The argument strings to parse.
   */
  public static void readArguments(String... args) {
    String currentArg = "";
    for (String arg : args) {
      arg = arg.trim();
      if (arg.startsWith("-")) {
        readFullArg(currentArg);
        currentArg = arg;
      } else {
        currentArg += ' ' + arg;
      }
    }
    readFullArg(currentArg);
    if (get(Boolean.class, "help")) {
      printHelp(null);
      System.exit(0);
    }
    try {
      for (Options<?> option : options.values()) {
        option.processRawDefaultValue();
        if (option.required && option.value == null) {
          System.err.println("A value must be specified for the " + option
              + " flag. Run with --help for options list.");
          System.exit(0);
        }
      }
      for (Options<?> option : options.values()) {
        if (option.value != null) {
          option.validate();
        }
      }
    } catch (BadOptionValueException e) {
      System.err.println(e.getMessage());
      System.exit(0);
    }
  }

  /**
   * Read a full argument consisting of a flag word (with dashed prefix) and
   * some number of option value words. If reading the argument fails, prints an
   * error message and exits the program.
   * @param fullArg The full argument string.
   */
  private static void readFullArg(String fullArg) {
    Preconditions.checkNotNull(fullArg);
    try {
      if (fullArg.length() > 0 && !activate(fullArg)) {
        printHelp(fullArg);
        System.exit(0);
      }
    } catch (BadOptionValueException e) {
      System.err.println(e.getMessage());
      System.exit(0);
    }
  }

  /**
   * Add a new option of the given object type with the given name. All program
   * options should be added before {@link #readArguments(String...)} is called.
   * Existing option names may not be redefined. Additional properties may
   * optionally be specified on the returned object. The object type of the
   * option may be ANY class, though a custom parser may be required depending
   * on the class type (see {@link #setParser(Parser)}).
   * @param <T> The object type of the new option.
   * @param clazz The class object for the option type.
   * @param name The unique name of the option.
   * @return The {@code Options} instance to specify additional properties.
   */
  public static <T> Options<T> add(Class<T> clazz, String name) {
    Preconditions.checkNotNull(clazz, "must provide an option class");
    Preconditions.checkArgument(name.length() > 0, "must provide an option name");
    Preconditions.checkState(!options.containsKey(name), "cannot redefine option " + name);
    Options<T> opt = new Options<T>(clazz, name);
    options.put(name, opt);
    orderedOptions.add(opt);
    return opt;
  }

  /**
   * Set a command string to appear in the first line or two of help messages as
   * "Usage: {@code usageCommand} [options]". If a usage string is not
   * specified, the class will attempt to guess an appropriate command based on
   * the name of the calling class, e.g. "Usage: java MyApp [options]".
   * @param usageCommand A string specifying the command used to run the
   *        program.
   */
  public static void setUsageString(String usageCommand) {
    usageCmd = usageCommand;
  }

  /**
   * Print a description of how to run the program and a listing of all
   * available command-line options with an optional message indicating an
   * invalid flag.
   * @param badArg An illegal command-line option or {@code null}.
   */
  public static void printHelp(String badArg) {
    if (badArg != null) {
      System.err.println("Invalid flag: '" + badArg + "'.");
    }
    if (usageCmd == null) {
      StackTraceElement[] st = new Throwable().getStackTrace();
      usageCmd = "java " + st[st.length - 1].getClassName();
    }
    System.out.println("Usage: " + usageCmd + " [options]");
    System.out.print("Option values are specified as --option <value>. ");
    System.out.println("Flags may omit the value.");
    System.out.println("Complete option list:");
    for (Options<?> option : orderedOptions) {
      String desc = option.description;
      if (option.defaultValue != null) {
        desc += " (def. " + option.defaultValue + ")";
      }
      if (option.shortFlag == NO_FLAG) {
        System.out.printf("  --%-18s %s\n", option, desc);
      } else {
        // keep descriptions correctly aligned
        int padLen = 15 - option.toString().length();
        System.out.printf("  --%s, -%-" + padLen + "s %s\n", option, option.shortFlag, desc);
      }
    }
  }

  /**
   * Get the option value for the specified option. The given option name must
   * specify a valid option and the given type must match the type provided when
   * the option was added or an exception will be thrown.
   * @param <T> The type of the option.
   * @param classType The class type.
   * @param optionName The name of the option.
   * @return The value of the option (may be null).
   */
  public static <T> T get(Class<T> classType, String optionName) {
    Preconditions.checkNotNull(classType);
    Preconditions.checkArgument(options.containsKey(optionName), "no such option " + optionName);
    Object value = options.get(optionName).value;
    if (value != null) {
      Class<?> classCheck = value.getClass();
      while (!classCheck.equals(classType)) {
        classCheck = classCheck.getSuperclass();
        if (classCheck == null) {
          throw new IllegalArgumentException("invalid option class type: '" + optionName
              + "' returned an instance of " + value.getClass().getCanonicalName() + ", not "
              + classType.getCanonicalName());
        }
      }
    }
    return classType.cast(value);
  }

  /**
   * Remove all non-standard options.
   */
  protected static void reset() {
    options.clear();
    shortOptions.clear();
    orderedOptions.clear();
    addStandardOptions();
  }

  /**
   * Activate an option and set its value if one was provided. The format of the
   * option string is -optionName=value, where the =value portion is optional.
   * If no specific value is given, the assumed option value is true.
   * @param opt The command line flag beginning with "-".
   * @return True if the option was set and false if the option doesn't exist.
   * @throws BadOptionValueException If the option has a bad value.
   */
  private static boolean activate(String opt) throws BadOptionValueException {
    Preconditions.checkArgument(opt.length() > 0);
    int valStart = opt.indexOf(' ');
    Options<?> option = null;
    if (opt.startsWith("--") && opt.length() > 2) {
      option = options.get((valStart == -1) ? opt.substring(2) : opt.substring(2, valStart));
    } else if (opt.startsWith("-") && (valStart == 2 || opt.length() == 2)) {
      option = shortOptions.get(opt.charAt(1));
    }
    if (option != null) {
      option.setValue((valStart == -1) ? "true" : opt.substring(valStart + 1));
      return true;
    }
    return false;
  }

  /**
   * Initialize option collection with standard options.
   */
  private static void addStandardOptions() {
    add(Boolean.class, "help").setDescription("Print a list of standard options").setShortFlag('h')
        .setDefaultValue(false);
  }

  /**
   * Indicates the absence of a short flag.
   */
  private static final char NO_FLAG = '-';

  /**
   * The current value of the option.
   */
  private T value;

  /**
   * The unprocessed value of the option (as given by the user).
   */
  private String rawValue;

  /**
   * The name of the option.
   */
  private final String name;

  /**
   * The class object for the option type.
   */
  private final Class<T> clazz;

  /**
   * A short version of the option for convenience.
   */
  private char shortFlag;

  /**
   * A description of the option.
   */
  private String description;

  /**
   * An unprocessed value.
   */
  private String rawDefaultValue;

  /**
   * The default value of the option, if applicable.
   */
  private T defaultValue;

  /**
   * Whether this option is required to have a value. Satisfied by a default
   * value as well as an assigned value.
   */
  private boolean required;

  /**
   * Parser for command-line strings into option values.
   */
  private Parser<T> parser;

  /**
   * Validator for checking parsed values.
   */
  private Validator<T> validator;

  /**
   * Create a new option object with the given class and name.
   * @param clazz The class object for the option type.
   * @param name The name of the option.
   */
  private Options(Class<T> clazz, String name) {
    this.clazz = clazz;
    this.name = name;
    this.description = "(no description available)";
    this.required = false;
    this.shortFlag = NO_FLAG;
  }

  /**
   * Set the default value of the option. The raw default value is only parsed
   * when arguments are parsed, so the parsing handler will not get called for
   * this default value unless it is actually used by the program. Once parsed,
   * the default value is still validated.
   * @return The modified option.
   */
  public Options<T> setRawDefaultValue(String defaultValue) {
    rawDefaultValue = defaultValue;
    return this;
  }

  /**
   * Process the option's given raw default value is it has been given one.
   * @throws BadOptionValueException If the raw default value cannot be parsed.
   */
  private void processRawDefaultValue() throws BadOptionValueException {
    if (rawDefaultValue != null) {
      try {
        value = ((parser == null) ? new DefaultParser() : parser).readInput(rawDefaultValue);
      } catch (FailedParseException e) {
        throw new BadOptionValueException(this, e.badInput, e.getMessage());
      }
    }
  }

  /**
   * Like {@link #setRawDefaultValue(String)}, but takes the object type itself
   * rather than an unparsed string. The given value is still validated.
   * @param defaultValue The actual object value to set.
   * @return The modified option.
   */
  public Options<T> setDefaultValue(T defaultValue) {
    value = defaultValue;
    return this;
  }

  /**
   * Set a one-line flag that may be used in place of the longer full option
   * name on the command line (prefixed by a single dash rather than two). A
   * single option should only have a single short flag. Existing short flags
   * may not be redefined and the character '-' may not be used as the flag.
   * @param shortFlag The short flag to use.
   * @return The modified option.
   */
  public Options<T> setShortFlag(char shortFlag) {
    Preconditions.checkArgument(shortFlag != NO_FLAG);
    this.shortFlag = shortFlag;
    Preconditions.checkState(!shortOptions.containsKey(shortFlag), "cannot redefine short option "
        + shortFlag);
    shortOptions.put(shortFlag, this);
    return this;
  }

  /**
   * Set a textual description of the option, to be printed alongside the option
   * in program help messages.
   * @param description A short description of the option.
   * @return The modified option.
   */
  public Options<T> setDescription(String description) {
    this.description = description;
    return this;
  }

  /**
   * Set whether the flag must be given a value when the program is called. If a
   * required flag is not given, the program will exit and print a help message.
   * By default, options are not required.
   * @param required Whether the option is required.
   * @return The modified option.
   */
  public Options<T> setRequired(boolean required) {
    this.required = required;
    return this;
  }

  /**
   * Specify a custom parser to parse strings passed on the command-line into
   * objects of the appropriate type. If no custom parser is specified, the
   * default parser calls the option class' public {@code (String)} constructor.
   * If the class has no such constructor, a custom parser must be specified or
   * the program will throw an exception during argument parsing. Most common
   * option types already have this constructor (e.g., {@code String}, {@code
   * Integer}, {@code Double}, and {@code Boolean}). However, if parsing fails
   * (for instance, if the value 2.5 is passed for an {@code Integer} option),
   * the default parser will only be able to print a generic error message.
   * @param parser A custom {@code String} to {@code T} parser.
   * @return The modified option.
   */
  public Options<T> setParser(Parser<T> parser) {
    this.parser = parser;
    return this;
  }

  /**
   * Specify a custom validator to check that given option values (after parsing
   * has occurred) are valid. Invalid values will cause a custom error message
   * to be given alongside the usual help message. If no custom validator is
   * specified, no validation check is performed.
   * @param validator A custom validator.
   * @return The modified option.
   */
  public Options<T> setValidator(Validator<T> validator) {
    this.validator = validator;
    return this;
  }

  /**
   * Validate the option's current value.
   * @throws BadOptionValueException If validation fails.
   */
  private void validate() throws BadOptionValueException {
    if (validator != null) {
      String error = validator.check(value);
      if (error != null) {
        throw new BadOptionValueException(this, rawValue, error);
      }
    }
  }

  /**
   * Return the name of the option.
   */
  @Override
  public String toString() {
    return name;
  }

  /**
   * Set the option value by parsing the input string.
   * @param valueString The raw input value.
   * @throws BadOptionValueException If the value is invalid for the option.
   */
  private void setValue(String valueString) throws BadOptionValueException {
    Preconditions.checkNotNull(valueString);
    try {
      value = ((parser == null) ? new DefaultParser() : parser).readInput(valueString);
    } catch (FailedParseException e) {
      throw new BadOptionValueException(this, e.badInput, e.getMessage());
    }
    rawValue = valueString;
  }

}

