/*
 *  Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you 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 groovy.cli.picocli

import groovy.cli.TypedOption
import org.codehaus.groovy.runtime.InvokerHelper
import org.codehaus.groovy.runtime.StringGroovyMethods
import picocli.CommandLine.Model.OptionSpec
import picocli.CommandLine.ParseResult

/**
 * Provides typed and dynamic access to values produced by {@link CliBuilder#parse(Object)}.
 */
class OptionAccessor {
    /**
     * The underlying picocli parse result.
     */
    ParseResult parseResult

    /**
     * Type metadata for options declared by the originating {@link CliBuilder}.
     */
    Map<String, TypedOption> savedTypeOptions

    /**
     * Creates an accessor for the specified parse result.
     *
     * @param parseResult the parsed command line state
     */
    OptionAccessor(ParseResult parseResult) {
        this.parseResult = parseResult
    }

    /**
     * Checks whether the specified typed option was matched.
     *
     * @param typedOption the option to inspect
     * @return {@code true} if the option was supplied on the command line
     */
    boolean hasOption(TypedOption typedOption) {
        parseResult.hasMatchedOption(typedOption.longOpt ?: typedOption.opt as String)
    }

    /**
     * Returns the converted default value for a named option.
     *
     * @param name the option name used to register the option metadata
     * @return the converted default value, or {@code null} if none was declared
     */
    public <T> T defaultValue(String name) {
        Class<T> type = savedTypeOptions[name]?.type
        String value = savedTypeOptions[name]?.defaultValue() ? savedTypeOptions[name].defaultValue() : null
        return (T) value ? getTypedValue(type, name, value) : null
    }

    /**
     * Returns the value of the specified typed option.
     *
     * @param typedOption the option to inspect
     * @return the parsed or configured value, or {@code null} if none is available
     */
    public <T> T getOptionValue(TypedOption<T> typedOption) {
        getOptionValue(typedOption, null)
    }

    /**
     * Returns the value of the specified typed option.
     *
     * @param typedOption the option to inspect
     * @param defaultValue the fallback value to use when the option has no value
     * @return the parsed value, configured default, or supplied fallback
     */
    public <T> T getOptionValue(TypedOption<T> typedOption, T defaultValue) {
        String optionName = (String) typedOption.longOpt ?: typedOption.opt
        if (parseResult.hasMatchedOption(optionName)) {
            return parseResult.matchedOptionValue(optionName, defaultValue)
        } else {
            OptionSpec option = parseResult.commandSpec().findOption(optionName)
            return option ? option.value : defaultValue
        }
    }

    /**
     * Returns the value of the specified typed option using subscript syntax.
     *
     * @param typedOption the option to inspect
     * @return the parsed or configured value, or {@code null} if none is available
     */
    public <T> T getAt(TypedOption<T> typedOption) {
        getAt(typedOption, null)
    }

    /**
     * Returns the value of the specified typed option using subscript syntax.
     *
     * @param typedOption the option to inspect
     * @param defaultValue the fallback value to use when the option has no value
     * @return the parsed value, configured default, or supplied fallback
     */
    public <T> T getAt(TypedOption<T> typedOption, T defaultValue) {
        getOptionValue(typedOption, defaultValue)
    }

    private <T> T getTypedValue(Class<T> type, String optionName, String optionValue) {
        if (savedTypeOptions[optionName]?.cliOption?.arity?.min == 0) { // TODO is this not a bug?
            return (T) parseResult.hasMatchedOption(optionName) // TODO should defaultValue not simply convert the type regardless of the matched value?
        }
        def convert = savedTypeOptions[optionName]?.convert
        return getValue(type, optionValue, convert)
    }

    private <T> T getValue(Class<T> type, String optionValue, Closure convert) {
        if (!type) {
            return (T) optionValue
        }
        if (Closure.isAssignableFrom(type) && convert) {
            return (T) convert(optionValue)
        }
        if (type == Boolean || type == Boolean.TYPE) {
            return type.cast(Boolean.parseBoolean(optionValue))
        }
        StringGroovyMethods.asType(optionValue, (Class<T>) type)
    }

    /**
     * Returns properties collected for a map-style option.
     *
     * @param name the option name
     * @return the parsed properties, or {@code null} if the option was not matched
     */
    Properties getOptionProperties(String name) {
        if (!parseResult.hasMatchedOption(name)) {
            return null
        }
        List<String> keyValues = parseResult.matchedOption(name).stringValues()
        Properties result = new Properties()
        keyValues.toSpreadMap().each { k, v -> result.setProperty(k, v) }
        result
    }

    /**
     * Provides limited Apache Commons CLI compatibility by delegating selected method calls to the underlying parse result.
     *
     * @param name the method name to invoke
     * @param args the method arguments
     * @return the delegated call result
     */
    def invokeMethod(String name, Object args) {
        // TODO we could just declare normal methods to map commons-cli CommandLine methods to picocli ParseResult methods
        if (name == 'hasOption')      { name = 'hasMatchedOption';   args = [args[0]      ].toArray() }
        if (name == 'getOptionValue') { name = 'matchedOptionValue'; args = [args[0], null].toArray() }
        return InvokerHelper.getMetaClass(parseResult).invokeMethod(parseResult, name, args)
    }

    /**
     * Resolves dynamic property lookups to parsed option values, defaults, or multi-value views.
     *
     * @param name the property name to resolve
     * @return the resolved value, or {@code false} when no matching option exists
     */
    def getProperty(String name) {
        if (name == 'parseResult') { return parseResult }
        if (parseResult.hasMatchedOption(name)) {
            def result = parseResult.matchedOptionValue(name, null)

            // if user specified an array type, return the full array (regardless of 's' suffix on name)
            Class userSpecifiedType = savedTypeOptions[name]?.type
            if (userSpecifiedType?.isArray()) { return result }

            // otherwise, if the result is multi-value, return the first value
            Class derivedType = parseResult.matchedOption(name).type()
            if (derivedType.isArray()) {
                return result ? result[0] : null
            } else if (Collection.class.isAssignableFrom(derivedType)) {
                return (result as Collection)?.first()
            }
            if (!userSpecifiedType && result == '' && parseResult.matchedOption(name).arity().min == 0) {
                return true
            }
            return parseResult.matchedOption(name).typedValues().get(0)
        }
        if (parseResult.commandSpec().findOption(name)) { // requested option was not matched: return its default
            def option = parseResult.commandSpec().findOption(name)
            def result = option.value

            // GROOVY-9519: zero default for non-Boolean type options should not be converted to false
            def longOpt = option.longestName()
            longOpt = longOpt?.startsWith("--") ? longOpt.substring(2) : longOpt
            Class userSpecifiedType = savedTypeOptions[longOpt]?.type
            if (userSpecifiedType && Boolean != userSpecifiedType) { return result }

            return result ? result : false
        }
        if (name.size() > 1 && name.endsWith('s')) { // user wants multi-value result
            def singularName = name[0..-2]
            if (parseResult.hasMatchedOption(singularName)) {
                // if picocli has a strongly typed multi-value result, return it
                Class type = parseResult.matchedOption(singularName).type()
                if (type.isArray() || Collection.class.isAssignableFrom(type) || Map.class.isAssignableFrom(type)) {
                    return parseResult.matchedOptionValue(singularName, null)
                }
                // otherwise, return the raw string values as a list
                return parseResult.matchedOption(singularName).stringValues()
            }
        }
        false
    }

    /**
     * Returns the remaining positional arguments.
     *
     * @return the unmatched positional arguments in encounter order
     */
    List<String> arguments() {
        parseResult.hasMatchedPositional(0) ? parseResult.matchedPositional(0).stringValues() : []
    }
}
