/*
 *  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
 *
 *      https://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 org.apache.tools.ant.taskdefs.optional.script;

import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import org.apache.tools.ant.AntTypeDefinition;
import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.ComponentHelper;
import org.apache.tools.ant.MagicNames;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.ProjectHelper;
import org.apache.tools.ant.taskdefs.DefBase;
import org.apache.tools.ant.types.ResourceCollection;
import org.apache.tools.ant.util.ClasspathUtils;
import org.apache.tools.ant.util.ScriptManager;
import org.apache.tools.ant.util.ScriptRunnerBase;
import org.apache.tools.ant.util.ScriptRunnerHelper;

/**
 * Defines a task using a script.
 *
 * @since Ant 1.6
 */
public class ScriptDef extends DefBase {
    /**
     * script runner helper
     */
    private ScriptRunnerHelper helper = new ScriptRunnerHelper();

    /** the name by which this script will be activated */
    private String name;

    /** Attributes definitions of this script */
    private List<Attribute> attributes = new ArrayList<>();

    /** Nested Element definitions of this script */
    private List<NestedElement> nestedElements = new ArrayList<>();

    /** The attribute names as a set */
    private Set<String> attributeSet;

    /** The nested element definitions indexed by their names */
    private Map<String, NestedElement> nestedElementMap;

    /**
     * Create a new {@link ScriptDef}.
     */
    public ScriptDef() {
        helper.setSetBeans(false);
    }

    /**
     * Set the project.
     * @param project the project that this definition belongs to.
     */
    @Override
    public void setProject(Project project) {
        super.setProject(project);
        helper.setProjectComponent(this);
    }

    /**
     * Sets the name under which this script will be activated in a build
     * file
     *
     * @param name the name of the script
     */
    public void setName(String name) {
        this.name = name;
    }

    /**
     * Indicates whether the task supports a given attribute name
     *
     * @param attributeName the name of the attribute.
     *
     * @return true if the attribute is supported by the script.
     */
    public boolean isAttributeSupported(String attributeName) {
        return attributeSet.contains(attributeName);
    }

    /**
     * Class representing an attribute definition
     */
    public static class Attribute {
        /** The attribute name */
        private String name;

        /** The attribute's default value */
        private String defaultValue;

        /**
         * Sets the attribute name.
         *
         * @param name the attribute name
         */
        public void setName(String name) {
            this.name = name.toLowerCase(Locale.ENGLISH);
        }

        /**
         * Get the name of this {@link Attribute}.
         * 
         * @return {@link String}
         */
        String getName() {
            return name;
        }

        /**
         * Set the default value of this {@link Attribute}.
         *
         * @param defaultValue {@link String}
         * @since Ant 1.10.13
         */
        public void setDefault(String defaultValue) {
            this.defaultValue = defaultValue;
        }

        /**
         * Get the default value of this {@link Attribute}, {@code null} if
         * unset.
         * 
         * @return {@link String}
         * @since Ant 1.10.13
         */
        String getDefault() {
            return defaultValue;
        }

        /**
         * Learn whether this {@link Attribute} has a default value set.
         * @return {@code boolean}
         */
        boolean hasDefault() {
            return defaultValue != null;
        }
    }

    /**
     * Adds an attribute definition to this script.
     *
     * @param attribute the attribute definition.
     */
    public void addAttribute(Attribute attribute) {
        attributes.add(attribute);
    }

    /**
     * Class to represent a nested element definition
     */
    public static class NestedElement {
        /** The name of the nested element */
        private String name;

        /** The Ant type to which this nested element corresponds. */
        private String type;

        /** The class to be created for this nested element */
        private String className;

        /**
         * Sets the tag name for this nested element
         *
         * @param name the name of this nested element
         */
        public void setName(String name) {
            this.name = name.toLowerCase(Locale.ENGLISH);
        }

        /**
         * Sets the type of this element. This is the name of an
         * Ant task or type which is to be used when this element is to be
         * created. This is an alternative to specifying the class name directly
         *
         * @param type the name of an Ant type, or task, to use for this nested
         * element.
         */
        public void setType(String type) {
            this.type = type;
        }

        /**
         * Sets the classname of the class to be used for the nested element.
         * This specifies the class directly and is an alternative to specifying
         * the Ant type name.
         *
         * @param className the name of the class to use for this nested
         * element.
         */
        public void setClassName(String className) {
            this.className = className;
        }
    }

    /**
     * Adds a nested element definition.
     *
     * @param nestedElement the nested element definition.
     */
    public void addElement(NestedElement nestedElement) {
        nestedElements.add(nestedElement);
    }

    /**
     * Defines the script.
     */
    @Override
    public void execute() {
        if (name == null) {
            throw new BuildException(
                "scriptdef requires a name attribute to name the script");
        }

        if (helper.getLanguage() == null) {
            throw new BuildException(
                "scriptdef requires a language attribute to specify the script language");
        }

        if (helper.getSrc() == null && helper.getEncoding() != null) {
            throw new BuildException(
                "scriptdef requires a src attribute if the encoding is set");
        }

        // Check if need to set the loader
        if (getAntlibClassLoader() != null || hasCpDelegate()) {
            helper.setClassLoader(createLoader());
        }

        attributeSet = new HashSet<>();
        for (Attribute attribute : attributes) {
            if (attribute.name == null) {
                throw new BuildException(
                    "scriptdef <attribute> elements must specify an attribute name");
            }
            if (attributeSet.contains(attribute.name)) {
                throw new BuildException(
                    "scriptdef <%s> declares the %s attribute more than once",
                    name, attribute.name);
            }
            attributeSet.add(attribute.name);
        }

        nestedElementMap = new HashMap<>();
        for (NestedElement nestedElement : nestedElements) {
            if (nestedElement.name == null) {
                throw new BuildException(
                    "scriptdef <element> elements must specify an element name");
            }
            if (nestedElementMap.containsKey(nestedElement.name)) {
                throw new BuildException(
                    "scriptdef <%s> declares the %s nested element more than once",
                    name, nestedElement.name);
            }

            if (nestedElement.className == null
                && nestedElement.type == null) {
                throw new BuildException(
                    "scriptdef <element> elements must specify either a classname or type attribute");
            }
            if (nestedElement.className != null
                && nestedElement.type != null) {
                throw new BuildException(
                    "scriptdef <element> elements must specify only one of the classname and type attributes");
            }
            nestedElementMap.put(nestedElement.name, nestedElement);
        }

        // find the script repository - it is stored in the project
        Map<String, ScriptDef> scriptRepository = lookupScriptRepository();
        name = ProjectHelper.genComponentName(getURI(), name);
        scriptRepository.put(name, this);
        AntTypeDefinition def = new AntTypeDefinition();
        def.setName(name);
        def.setClass(ScriptDefBase.class);
        ComponentHelper.getComponentHelper(
            getProject()).addDataTypeDefinition(def);
    }

    /**
     * Finds or creates the script repository - it is stored in the project.
     * This method is synchronized on the project under {@link MagicNames#SCRIPT_REPOSITORY}
     * @return the current script repository registered as a reference.
     */
    private Map<String, ScriptDef> lookupScriptRepository() {
        Map<String, ScriptDef> scriptRepository;
        Project p = getProject();
        synchronized (p) {
            scriptRepository =
                    p.getReference(MagicNames.SCRIPT_REPOSITORY);
            if (scriptRepository == null) {
                scriptRepository = new HashMap<>();
                p.addReference(MagicNames.SCRIPT_REPOSITORY,
                        scriptRepository);
            }
        }
        return scriptRepository;
    }

    /**
     * Creates a nested element to be configured.
     *
     * @param elementName the name of the nested element.
     * @return object representing the element name.
     */
    public Object createNestedElement(String elementName) {
        NestedElement definition = nestedElementMap.get(elementName);
        if (definition == null) {
            throw new BuildException(
                "<%s> does not support the <%s> nested element", name,
                elementName);
        }

        Object instance;
        String classname = definition.className;
        if (classname == null) {
            instance = getProject().createTask(definition.type);
            if (instance == null) {
                instance = getProject().createDataType(definition.type);
            }
        } else {
            ClassLoader loader = createLoader();

            try {
                instance = ClasspathUtils.newInstance(classname, loader);
            } catch (BuildException e) {
                instance = ClasspathUtils.newInstance(classname, ScriptDef.class.getClassLoader());
            }
            getProject().setProjectReference(instance);
        }

        if (instance == null) {
            throw new BuildException(
                "<%s> is unable to create the <%s> nested element", name,
                elementName);
        }
        return instance;
    }

    /**
     * Executes the script.
     *
     * @param attributes collection of attributes
     * @param elements a list of nested element values.
     * @deprecated since 1.7.
     *             Use executeScript(attribute, elements, instance) instead.
     */
    @Deprecated
    public void executeScript(Map<String, String> attributes,
        Map<String, List<Object>> elements) {
        executeScript(attributes, elements, null);
    }

    /**
     * Executes the script.
     * This is called by the script instance to execute the script for this
     * definition.
     *
     * @param attributes collection of attributes
     * @param elements   a list of nested element values.
     * @param instance   the script instance; can be null
     */
    public void executeScript(Map<String, String> attributes,
        Map<String, List<Object>> elements, ScriptDefBase instance) {
        ScriptRunnerBase runner = helper.getScriptRunner();

        runner.addBean("attributes", withDefault(attributes));
        runner.addBean("elements", elements);
        runner.addBean("project", getProject());
        if (instance != null) {
            runner.addBean("self", instance);
        }
        runner.executeScript("scriptdef_" + name);
    }

    /**
     * Defines the manager.
     *
     * @param manager the scripting manager.
     */
    @Deprecated
    public void setManager(String manager) {
        helper.setManager(manager);
    }

    /**
     * Set the script manager.
     * @param manager
     */
    public void setManager(ScriptManager manager) {
        helper.setManager(manager);
    }

    /**
     * Defines the language (required).
     *
     * @param language the scripting language name for the script.
     */
    public void setLanguage(String language) {
        helper.setLanguage(language);
    }

    /**
     * Defines the compilation feature; optional.
     *
     * @param compiled enables the script compilation if available.
     * @since Ant 1.10.2
     */
    public void setCompiled(boolean compiled) {
        helper.setCompiled(compiled);
    }

    /**
     * Loads the script from an external file; optional.
     *
     * @param file the file containing the script source.
     */
    public void setSrc(File file) {
        helper.setSrc(file);
    }

    /**
     * Sets the encoding of the script from an external file; optional.
     *
     * @param encoding the encoding of the file containing the script source.
     * @since Ant 1.10.2
     */
    public void setEncoding(String encoding) {
        helper.setEncoding(encoding);
    }

    /**
     * Set the setbeans attribute.
     * If this is true, &lt;script&gt; will create variables in the
     * script instance for all
     * properties, targets and references of the current project.
     * It this is false, only the project and self variables will
     * be set.
     * The default is true.
     * @param setBeans the value to set.
     */
    public void setSetBeans(boolean setBeans) {
        helper.setSetBeans(setBeans);
    }

    /**
     * Sets the script text.
     *
     * @param text a component of the script text to be added.
     */
    public void addText(String text) {
        helper.addText(text);
    }

    /**
     * Adds any source resource.
     * @since Ant 1.7.1
     * @param resource source of script
     */
    public void add(ResourceCollection resource) {
        helper.add(resource);
    }

    private Map<String, String> withDefault(Map<String, String> attributes) {
        /*
         * The following is checked in ScriptDefBase but some other caller to
         * #executeScript() could pass in anything they like
         */
        final Set<String> unsupported =
            attributeSet.stream().filter(a -> !this.isAttributeSupported(a)).map(s -> '@' + s)
                .collect(Collectors.toSet());

        if (!unsupported.isEmpty()) {
            throw new BuildException("Found unsupported attributes " + unsupported);
        }
        if (this.attributes.isEmpty()) {
            return attributes;
        }
        final Map<String, String> result =
            this.attributes.stream().filter(Attribute::hasDefault).collect(Collectors
                .toMap(Attribute::getName, Attribute::getDefault, (l, r) -> r, LinkedHashMap::new));

        result.putAll(attributes);

        return result;
    }
}
