/*
 * Copyright (C) 2010 The Android Open Source Project
 *
 * 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 com.android.tradefed.config;

import com.android.tradefed.log.LogUtil.CLog;
import com.android.tradefed.result.error.InfraErrorIdentifier;
import com.android.tradefed.util.ArrayUtil;
import com.android.tradefed.util.keystore.DryRunKeyStore;
import com.android.tradefed.util.keystore.IKeyStoreClient;

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Populates {@link Option} fields from parsed command line arguments.
 * <p/>
 * Strings in the passed-in String[] are parsed left-to-right. Each String is classified as a short
 * option (such as "-v"), a long option (such as "--verbose"), an argument to an option (such as
 * "out.txt" in "-f out.txt"), or a non-option positional argument.
 * <p/>
 * Each option argument must map to one or more {@link Option} fields. A long option maps to the
 * {@link Option} name, and a short option maps to {@link Option} short name. Each option name and
 * option short name must be unique with respect to all other
 * {@link Option} fields within the same object.
 * <p/>
 * A single option argument can get mapped to multiple {@link Option} fields with the same name
 * across multiple objects. {@link Option} arguments can be namespaced to uniquely refer to an
 * {@link Option} field within a single object using that object's full class name or its
 * {@link OptionClass} alias value separated by ':'. ie
 *
 * <pre>
 * --classname:optionname optionvalue or
 * --optionclassalias:optionname optionvalue.
 * </pre>
 * <p/>
 * A simple short option is a "-" followed by a short option character. If the option requires an
 * argument (which is true of any non-boolean option), it may be written as a separate parameter,
 * but need not be. That is, "-f out.txt" and "-fout.txt" are both acceptable.
 * <p/>
 * It is possible to specify multiple short options after a single "-" as long as all (except
 * possibly the last) do not require arguments.
 * <p/>
 * A long option begins with "--" followed by several characters. If the option requires an
 * argument, it may be written directly after the option name, separated by "=", or as the next
 * argument. (That is, "--file=out.txt" or "--file out.txt".)
 * <p/>
 * A boolean long option '--name' automatically gets a '--no-name' companion. Given an option
 * "--flag", then, "--flag", "--no-flag", "--flag=true" and "--flag=false" are all valid, though
 * neither "--flag true" nor "--flag false" are allowed (since "--flag" by itself is sufficient, the
 * following "true" or "false" is interpreted separately). You can use "yes" and "no" as synonyms
 * for "true" and "false".
 * <p/>
 * Each String not starting with a "-" and not a required argument of a previous option is a
 * non-option positional argument, as are all successive Strings. Each String after a "--" is a
 * non-option positional argument.
 * <p/>
 * The fields corresponding to options are updated as their options are processed. Any remaining
 * positional arguments are returned as a List&lt;String&gt;.
 * <p/>
 * Here's a simple example:
 * <p/>
 *
 * <pre>
 * // Non-&#64;Option fields will be ignored.
 * class Options {
 *     &#64;Option(name = "quiet", shortName = 'q')
 *     boolean quiet = false;
 *
 *     // Here the user can use --no-color.
 *     &#64;Option(name = "color")
 *     boolean color = true;
 *
 *     &#64;Option(name = "mode", shortName = 'm')
 *     String mode = "standard; // Supply a default just by setting the field.
 *
 *     &#64;Option(name = "port", shortName = 'p')
 *     int portNumber = 8888;
 *
 *     // There's no need to offer a short name for rarely-used options.
 *     &#64;Option(name = "timeout" )
 *     double timeout = 1.0;
 *
 *     &#64;Option(name = "output-file", shortName = 'o' })
 *     File output;
 *
 *     // Multiple options are added to the collection.
 *     // The collection field itself must be non-null.
 *     &#64;Option(name = "input-file", shortName = 'i')
 *     List&lt;File&gt; inputs = new ArrayList&lt;File&gt;();
 *
 * }
 *
 * Options options = new Options();
 * List&lt;String&gt; posArgs = new OptionParser(options).parse("--input-file", "/tmp/file1.txt");
 * for (File inputFile : options.inputs) {
 *     if (!options.quiet) {
 *        ...
 *     }
 *     ...
 *
 * }
 *
 * </pre>
 *
 * See also:
 * <ul>
 * <li>the getopt(1) man page
 * <li>Python's "optparse" module (http://docs.python.org/library/optparse.html)
 * <li>the POSIX "Utility Syntax Guidelines"
 * (http://www.opengroup.org/onlinepubs/000095399/basedefs/xbd_chap12.html#tag_12_02)
 * <li>the GNU "Standards for Command Line Interfaces"
 * (http://www.gnu.org/prep/standards/standards.html#Command_002dLine-Interfaces)
 * </ul>
 *
 * @see OptionSetter
 */
public class ArgsOptionParser extends OptionSetter {

    static final String SHORT_NAME_PREFIX = "-";
    static final String OPTION_NAME_PREFIX = "--";
    // For a boolean pattern match:  {device name}namespace:(no-)option-name
    static final Pattern BOOL_FALSE_DEVICE_PATTERN = Pattern.compile("(\\{.*\\})(.*:)?(no-)(.+)");
    // For a boolean pattern match:  namespace(:idx):(no-)option-name
    static final Pattern BOOL_FALSE_PATTERN = Pattern.compile("(.*:)(.*:)(no-)(.+)");

    /** the amount to indent an option field's description when displaying help */
    private static final int OPTION_DESCRIPTION_INDENT = 25;

    private Set<String> inopOptions = new HashSet<>();

    /** Returns the set of options that did not change any default values. */
    public Set<String> getInopOptions() {
        return inopOptions;
    }

    /**
     * Creates a {@link ArgsOptionParser} for a collection of objects.
     *
     * @param optionSources the config objects.
     * @throws ConfigurationException if config objects is improperly configured.
     */
    public ArgsOptionParser(Collection<Object> optionSources) throws ConfigurationException {
        super(optionSources);
    }

    /**
     * Creates a {@link ArgsOptionParser} for one or more objects.
     *
     * @param optionSources the config objects.
     * @throws ConfigurationException if config objects is improperly configured.
     */
    public ArgsOptionParser(Object... optionSources) throws ConfigurationException {
        super(optionSources);
    }

    /**
     * Parses the command-line arguments 'args', setting the @Option fields of the 'optionSource'
     * provided to the constructor.
     *
     * @return a {@link List} of the positional arguments left over after processing all options.
     * @throws ConfigurationException if error occurred parsing the arguments.
     */
    public List<String> parse(String... args) throws ConfigurationException {
        return parse(Arrays.asList(args));
    }

    /**
     * Alternate {@link #parse(String... args)} method that takes a {@link List} of arguments
     *
     * @return a {@link List} of the positional arguments left over after processing all options.
     * @throws ConfigurationException if error occurred parsing the arguments.
     */
    public List<String> parse(List<String> args) throws ConfigurationException {
        final List<String> leftovers = new ArrayList<String>();
        final ListIterator<String> argsIter = args.listIterator();

        // Scan 'args'.
        while (argsIter.hasNext() && parseArg(argsIter.next(), argsIter, leftovers)) {
            // This loop has no body.  All of the work happens by side-effect in parseArg(...)
        }

        // Package up the leftovers.
        while (argsIter.hasNext()) {
            leftovers.add(argsIter.next());
        }
        return leftovers;
    }

    /**
     * A best-effort version of {@link #parse(String... args)}.  If a ConfigurationException is
     * thrown, that exception is captured internally, and the remaining arguments (including the
     * argument which caused the exception to be thrown) are returned.  This method does not throw.
     *
     * @return a {@link List} of the left over arguments
     */
    public List<String> parseBestEffort(String... args) {
        return parseBestEffort(Arrays.asList(args));
    }

    /**
     * Alternate {@link #parseBestEffort(String... args)} method that takes a {@link List} of
     * arguments
     *
     * @return a {@link List} of the left over arguments
     */
    public List<String> parseBestEffort(List<String> args) {
        return parseBestEffort(args, false);
    }

    /**
     * Alternate {@link #parseBestEffort(String... args)} method that takes a {@link List} of
     * arguments, and can be forced to continue parsing until the end, even if some args do not
     * parse.
     *
     * @param args list that will contain the left over args.
     * @param forceContinue True if it should continue to parse even if some args do not parse.
     * @return a {@link List} of the left over arguments
     */
    public List<String> parseBestEffort(List<String> args, boolean forceContinue) {
        final List<String> leftovers = new ArrayList<String>(args.size());
        final ListIterator<String> argsIter = args.listIterator();
        int lastProcessedIdx = -1;

        /* (not javadoc)
         * Scan 'args'.  Note that each call to parseArg(...) will advance argsIter a number
         * of places that depends on the arity of the particular Option being processed.  For a
         * boolean Option, it will not advance.  For a standard unary Option, it will advance
         * 1 place.  For a Map Option, it will advance two places (1 for key, 1 for value).
         *
         * For this reason, we grab the index from the iterator itself, rather than trying to
         * increment it ourselves.
         */
        boolean lastCheckThrew = false;
        while (argsIter.hasNext()) {
            lastProcessedIdx = argsIter.nextIndex();
            final String arg = argsIter.next();
            try {
                // All of the work happens within parseArg(...) by side-effect
                if (!parseArg(arg, argsIter, leftovers)) {
                    if (!lastCheckThrew) {
                        break;
                    }
                }
                lastCheckThrew = false;
            } catch (ConfigurationException e) {
                lastCheckThrew = true;
                // Something failed.  Add all of the not-fully-processed and not-yet-processed args
                // to leftovers and return
                if (!forceContinue) {
                    leftovers.addAll(args.subList(lastProcessedIdx, args.size()));
                    return leftovers;
                } else {
                    leftovers.add(arg);
                }
            }
        }

        // Package up the leftovers.
        while (argsIter.hasNext()) {
            leftovers.add(argsIter.next());
        }
        return leftovers;
    }

    /**
     * Validates that all fields marked as mandatory have been set.
     * @throws ConfigurationException
     */
    public void validateMandatoryOptions() throws ConfigurationException {
        // Make sure that all mandatory options have been specified
        List<String> missingOptions = new ArrayList<String>(getUnsetMandatoryOptions());
        if (!missingOptions.isEmpty()) {
            throw new ConfigurationException(String.format("Found missing mandatory options: %s",
                    ArrayUtil.join(", ", missingOptions)));
        }
    }

    /**
     * Attempts to parse the specified argument.
     *
     * @return {@code true} if iteration should continue, or {@code false} if iteration should stop
     */
    private boolean parseArg(String arg, ListIterator<String> args, List<String> leftovers)
            throws ConfigurationException {
        if (arg.equals(OPTION_NAME_PREFIX)) {
            // "--" marks the end of options and the beginning of positional arguments.
            return false;

        } else if (arg.startsWith(OPTION_NAME_PREFIX)) {
            // A long option.
            parseLongOption(arg, args);
            return true;

        } else if (arg.startsWith(SHORT_NAME_PREFIX)) {
            // A short option.
            parseGroupedShortOptions(arg, args);
            return true;

        } else {
            // The first non-option marks the end of options.
            leftovers.add(arg);
            return false;
        }
    }

    private void parseLongOption(String arg, ListIterator<String> args)
            throws ConfigurationException {
        // remove prefix to just get name
        String name = arg.replaceFirst("^" + OPTION_NAME_PREFIX, "");
        String key = null;
        String value = null;

        // Support "--name=value" as well as "--name value".
        final int equalsIndex = name.indexOf('=');
        if (equalsIndex != -1) {
            value = name.substring(equalsIndex + 1);
            name = name.substring(0, equalsIndex);
        }

        if (isBooleanOption(name)) {
            int idx = name.indexOf(NAMESPACE_SEPARATOR);
            // Detect a device tag in front of the boolean option.
            Matcher m = BOOL_FALSE_DEVICE_PATTERN.matcher(name);
            if (m.find()) {
                value = "false";
            } else {
                // default boolean flag to true and overwrite it if value is provided by user
                if (value == null) {
                    value = "true";
                }
                String nextArg = args.hasNext() ? args.next() : null;
                if (nextArg != null) {
                    if (nextArg.equalsIgnoreCase("true") || nextArg.equalsIgnoreCase("false")) {
                        value = nextArg.toLowerCase();
                    } else {
                        // if the next arg is not "true" or "false", move the pointer back
                        args.previous();
                    }
                }
                Matcher boolNamespace = BOOL_FALSE_PATTERN.matcher(name);
                if (boolNamespace.find()) {
                    value = "false";
                } else {
                    if (name.startsWith(BOOL_FALSE_PREFIX, idx + 1)) {
                        if (value.equals("false")) {
                            // if user entered "--no-boolean-flag false"
                            throw new ConfigurationException(
                                    String.format(
                                            "Can not use 'no-' prefix on a boolean flag with a"
                                                + " 'false' value. Please use '--%1$s' instead of"
                                                + " '--%2$s false'",
                                            name.substring(BOOL_FALSE_PREFIX.length() + idx + 1),
                                            name),
                                    InfraErrorIdentifier.OPTION_CONFIGURATION_ERROR);
                        }
                        value = "false";
                    }
                }
            }
        }
        if (value == null) {
            if (isMapOption(name)) {
                // Support --option key=value and --option key value format
                String tmp = grabNextValue(args, name, "for its key");
                // only match = to escape use "\="
                Pattern p = Pattern.compile("(?<!\\\\)=");
                String[] parts = p.split(tmp, /* allow empty-string values */ -1);
                // Note that we replace escaped = (\=) to =.
                if (parts.length == 2) {
                    key = parts[0].replaceAll("\\\\=", "=");
                    value = parts[1].replaceAll("\\\\=", "=");
                } else if (parts.length > 2) {
                    throw new ConfigurationException(
                            String.format(
                                    "option '%s' has an invalid format for value %s:w", name, tmp),
                            InfraErrorIdentifier.OPTION_CONFIGURATION_ERROR);
                } else {
                    key = tmp.replaceAll("\\\\=", "=");
                    value = grabNextValue(args, name, "for its value").replaceAll("\\\\=", "=");
                }
            } else {
                value = grabNextValue(args, name);
            }
        }
        value = getKeyStoreValueIfNeeded(value, getTypeForOption(name));
        List<FieldDef> modifiedField = setOptionValue(name, key, value);
        if (modifiedField.isEmpty()) {
            inopOptions.add(name);
        }
    }

    // Given boolean options a and b, and non-boolean option f, we want to allow:
    // -ab
    // -abf out.txt
    // -abfout.txt
    // (But not -abf=out.txt --- POSIX doesn't mention that either way, but GNU expressly forbids
    // it.)
    private void parseGroupedShortOptions(String arg, ListIterator<String> args)
            throws ConfigurationException {
        for (int i = 1; i < arg.length(); ++i) {
            final String name = String.valueOf(arg.charAt(i));
            String value;
            if (isBooleanOption(name)) {
                value = "true";
            } else {
                // We need a value. If there's anything left, we take the rest of this
                // "short option".
                if (i + 1 < arg.length()) {
                    value = arg.substring(i + 1);
                    i = arg.length() - 1;
                } else {
                    value = grabNextValue(args, name);
                }
            }
            value = getKeyStoreValueIfNeeded(value, getTypeForOption(name));
            List<FieldDef> modifiedField = setOptionValue(name, value);
            if (modifiedField.isEmpty()) {
                inopOptions.add(name);
            }
        }
    }

    /**
     * Returns the next element of 'args' if there is one. Uses 'name' to construct a helpful error
     * message.
     *
     * @param args the arg iterator
     * @param name the name of current argument
     * @throws ConfigurationException if no argument is present
     *
     * @returns the next element
     */
    private String grabNextValue(ListIterator<String> args, String name)
            throws ConfigurationException {
        return grabNextValue(args, name, "");
    }

    /**
     * Returns the next element of 'args' if there is one. Uses 'name' to construct a helpful error
     * message.
     *
     * @param args the arg iterator
     * @param name the name of current argument
     * @param detail a string to append to the ConfigurationException message, if one is thrown
     * @throws ConfigurationException if no argument is present
     *
     * @returns the next element
     */
    private String grabNextValue(ListIterator<String> args, String name, String detail)
            throws ConfigurationException {
        if (!args.hasNext()) {
            String type = getTypeForOption(name);
            throw new ConfigurationException(String.format("option '%s' requires a '%s' argument%s",
                    name, type, detail));
        }
        return args.next();
    }

    /**
     * Output help text for all {@link Option} fields in <param>optionObject</param>.
     * <p/>
     * The help text for each option will be in the following format
     * <pre>
     *   [-option_shortname, --option_name]          [option_description] Default:
     *   [current option field's value in optionObject]
     * </pre>
     * The 'Default..." text will be omitted if the option field is null or empty.
     *
     * @param importantOnly if <code>true</code> only print help for the important options
     * @param optionObject the object to print help text for
     * @return a String containing user-friendly help text for all Option fields
     */
    public static String getOptionHelp(boolean importantOnly, Object optionObject) {
        StringBuilder out = new StringBuilder();
        Collection<Field> optionFields = OptionSetter.getOptionFieldsForClass(
                optionObject.getClass());
        String eol = System.getProperty("line.separator");
        for (Field field : optionFields) {
            final Option option = field.getAnnotation(Option.class);
            String defaultValue = OptionSetter.getFieldValueAsString(field, optionObject);
            String optionNameHelp = buildOptionNameHelp(field, option);
            if (shouldOutputHelpForOption(importantOnly, option, defaultValue)) {
                out.append(optionNameHelp);
                // insert appropriate whitespace between the name help and the description, to
                // ensure consistent alignment
                int wsChars = 0;
                if (optionNameHelp.length() >= OPTION_DESCRIPTION_INDENT) {
                    // name help is too long, break description onto next line
                    out.append(eol);
                    wsChars = OPTION_DESCRIPTION_INDENT;
                } else {
                    // insert enough whitespace so option.description starts at
                    // OPTION_DESCRIPTION_INDENT
                    wsChars = OPTION_DESCRIPTION_INDENT - optionNameHelp.length();
                }
                for (int i = 0; i < wsChars; ++i) {
                    out.append(' ');
                }
                out.append(option.description());
                out.append(getDefaultValueHelp(defaultValue));
                out.append(OptionSetter.getEnumFieldValuesAsString(field));
                out.append(eol);
            }
        }
        return out.toString();
    }

    /**
     * Determine if help for given option should be displayed.
     *
     * @param importantOnly
     * @param option
     * @param defaultValue
     * @return <code>true</code> if help for option should be displayed
     */
    private static boolean shouldOutputHelpForOption(boolean importantOnly, Option option,
            String defaultValue) {
        if (!importantOnly) {
            return true;
        }
        switch (option.importance()) {
            case NEVER:
                return false;
            case IF_UNSET:
                return defaultValue == null;
            case ALWAYS:
                return true;
        }
        CLog.e("Unrecognized importance setting '%s'", option.importance().toString());
        return false;
    }

    /**
     * Builds the 'name' portion of the help text for the given option field
     *
     * @param field
     * @param option
     * @return the help text that describes the option flags
     */
    private static String buildOptionNameHelp(Field field, final Option option) {
        StringBuilder optionNameBuilder = new StringBuilder();
        optionNameBuilder.append("    ");
        if (option.shortName() != Option.NO_SHORT_NAME) {
            optionNameBuilder.append(SHORT_NAME_PREFIX);
            optionNameBuilder.append(option.shortName());
            optionNameBuilder.append(", ");
        }
        optionNameBuilder.append(OPTION_NAME_PREFIX);
        try {
            if (OptionSetter.isBooleanField(field)) {
                optionNameBuilder.append("[no-]");
            }
        } catch (ConfigurationException e) {
            // ignore
        }
        optionNameBuilder.append(option.name());
        return optionNameBuilder.toString();
    }

    /**
     * Returns the help text describing the given default value
     *
     * @param defaultValue the default value
     * @return the help text, or an empty {@link String} if <param>field</param> has no value
     */
    private static String getDefaultValueHelp(String defaultValue) {
        if (defaultValue == null) {
            return "";
        } else {
            return String.format(" Default: %s.", defaultValue);
        }
    }

    /**
     * Replaces value with key store value if needed
     *
     * @param valueText the value
     * @param optionType the type of the value expected in the option.
     * @return the value or the translated key store value.
     * @throws ConfigurationException
     */
    private String getKeyStoreValueIfNeeded(String valueText, String optionType)
            throws ConfigurationException {
        Matcher m = USE_KEYSTORE_REGEX.matcher(valueText);
        if (m.matches() && m.groupCount() > 0) {
            IKeyStoreClient c = getKeyStore();
            if (c == null) {
                throw new ConfigurationException(
                        "Key store is null, but we tried to fetch a key",
                        InfraErrorIdentifier.KEYSTORE_CONFIG_ERROR);
            }
            if (!c.isAvailable()) {
                throw new ConfigurationException(
                        String.format(
                                "Key store '%s' is unavailable, but " + "we tried to fetch a key",
                                c.getClass()),
                        InfraErrorIdentifier.KEYSTORE_CONFIG_ERROR);
            }
            String key = m.group(1);
            String v = null;
            // if it's the special dry-run keystore, we use a special handling and don't throw.
            if (c instanceof DryRunKeyStore) {
                v = ((DryRunKeyStore) c).fetchKey(key, optionType);
            } else {
                v = c.fetchKey(key);
            }
            if (v == null) {
                throw new ConfigurationException(
                        String.format("Failed to fetch key %s in keystore", key),
                        InfraErrorIdentifier.KEYSTORE_CONFIG_ERROR);
            }
            return v;
        }
        // Did not match the key store pattern, do nothing.
        return valueText;
    }
}
