/*
 *  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.util;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;

import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.ProjectComponent;
import org.apache.tools.ant.types.Resource;
import org.apache.tools.ant.types.ResourceCollection;
import org.apache.tools.ant.types.resources.PropertyResource;
import org.apache.tools.ant.types.resources.StringResource;

/**
 * This is a common abstract base case for script runners.
 * These classes need to implement executeScript, evaluateScript
 * and supportsLanguage.
 * @since Ant 1.7.0
 */
public abstract class ScriptRunnerBase {
    /** Whether to keep the engine between calls to execute/eval */
    private boolean keepEngine = false;

    /** Script language */
    private String language;

    /** Script content */
    private String script = "";

    private String encoding;

    /** Enable script compilation. */
    private boolean compiled;

    /** Project this runner is used in */
    private Project project;

    /** Classloader to be used when running the script. */
    private ClassLoader scriptLoader;

    /** Beans to be provided to the script */
    private final Map<String, Object> beans = new HashMap<>();

    /**
     * Add a list of named objects to the list to be exported to the script
     *
     * @param dictionary a map of objects to be placed into the script context
     *        indexed by String names.
     */
    public void addBeans(Map<String, ?> dictionary) {
        dictionary.forEach((k, v) -> {
            try {
                addBean(k, v);
            } catch (BuildException ex) {
                // The key is in the dictionary but cannot be retrieved
                // This is usually due references that refer to tasks
                // that have not been taskdefed in the current run.
                // Ignore
            }
        });
    }

    /**
     * Add a single object into the script context.
     *
     * @param key the name in the context this object is to stored under.
     * @param bean the object to be stored in the script context.
     */
    public void addBean(String key, Object bean) {
        if (!key.isEmpty() && Character.isJavaIdentifierStart(key.charAt(0))
                && key.chars().skip(1).allMatch(Character::isJavaIdentifierPart)) {
            beans.put(key, bean);
        }
    }

    /**
     * Get the beans used for the script.
     * @return the map of beans.
     */
    protected Map<String, Object> getBeans() {
        return beans;
    }

    /**
     * Do the work.
     * @param execName the name that will be passed to BSF for this script
     *        execution.
     */
    public abstract void executeScript(String execName);

    /**
     * Evaluate the script.
     * @param execName the name that will be passed to the
     *                 scripting engine for this script execution.
     * @return the result of evaluating the script.
     */
    public abstract Object evaluateScript(String execName);

    /**
     * Check if a script engine can be created for
     * this language.
     * @return true if a script engine can be created, false
     *              otherwise.
     */
    public abstract boolean supportsLanguage();

    /**
     * Get the name of the manager prefix used for this
     * scriptrunner.
     * @return the prefix string.
     */
    public abstract String getManagerName();

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

    /**
     * Get the script language
     * @return the script language
     */
    public String getLanguage() {
        return language;
    }

    /**
     * Set the script classloader.
     * @param classLoader the classloader to use.
     */
    public void setScriptClassLoader(ClassLoader classLoader) {
        this.scriptLoader = classLoader;
    }

    /**
     * Get the classloader used to load the script engine.
     * @return the classloader.
     */
    protected ClassLoader getScriptClassLoader() {
        return scriptLoader;
    }

    /**
     * Whether to keep the script engine between calls.
     * @param keepEngine if true, keep the engine.
     */
    public void setKeepEngine(boolean keepEngine) {
        this.keepEngine = keepEngine;
    }

    /**
     * Get the keep engine attribute.
     * @return the attribute.
     */
    public boolean getKeepEngine() {
        return keepEngine;
    }

    /**
     * Whether to use script compilation if available.
     * @since Ant 1.10.2
     * @param compiled if true, compile the script if possible.
     */
    public final void setCompiled(boolean compiled) {
        this.compiled = compiled;
    }

    /**
     * Get the compiled attribute.
     * @since Ant 1.10.2
     * @return the attribute.
     */
    public final boolean getCompiled() {
        return compiled;
    }

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

    /**
     * Load the script from an external file; optional.
     * @param file the file containing the script source.
     */
    public void setSrc(File file) {
        String filename = file.getPath();
        if (!file.exists()) {
            throw new BuildException("file " + filename + " not found.");
        }

        try (InputStream in = Files.newInputStream(file.toPath())) {
            final Charset charset = null == encoding ? Charset.defaultCharset()
                : Charset.forName(encoding);

            readSource(in, filename, charset);
        } catch (IOException e) {
            //this can only happen if the file got deleted a short moment ago
            throw new BuildException("file " + filename + " not found.", e);
        }
    }

    /**
     * Read some source in from the given reader
     * @param in the input stream to pass into a buffered reader.
     * @param name the name to use in error messages
     * @param charset the encoding for the reader, may be null.
     */
    private void readSource(InputStream in, String name, Charset charset) {
        try (Reader reader =
            new BufferedReader(new InputStreamReader(in, charset))) {
            script += FileUtils.safeReadFully(reader);
        } catch (IOException ex) {
            throw new BuildException("Failed to read " + name, ex);
        }
    }

    /**
     * Add a resource to the source list.
     * @since Ant 1.7.1
     * @param sourceResource the resource to load
     * @throws BuildException if the resource cannot be read
     */
    public void loadResource(Resource sourceResource) {
        if (sourceResource instanceof StringResource) {
            script += ((StringResource) sourceResource).getValue();
            return;
        }
        if (sourceResource instanceof PropertyResource) {
            script += ((PropertyResource) sourceResource).getValue();
            return;
        }

        String name = sourceResource.toLongString();
        try (InputStream in = sourceResource.getInputStream()) {
            readSource(in, name, Charset.defaultCharset());
        } catch (IOException e) {
            throw new BuildException("Failed to open " + name, e);
        } catch (UnsupportedOperationException e) {
            throw new BuildException(
                "Failed to open " + name + " - it is not readable", e);
        }
    }

    /**
     * Add all resources in a resource collection to the source list.
     * @since Ant 1.7.1
     * @param collection the resource to load
     * @throws BuildException if a resource cannot be read
     */
    public void loadResources(ResourceCollection collection) {
        collection.forEach(this::loadResource);
    }

    /**
     * Set the script text. Properties in the text are not expanded!
     *
     * @param text a component of the script text to be added.
     */
    public void addText(String text) {
        script += text;
    }

    /**
     * Get the current script text content.
     * @return the script text.
     */
    public String getScript() {
        return script;
    }

    /**
     * Clear the current script text content.
     */
    public void clearScript() {
        this.script = "";
    }

    /**
     * Set the project for this runner.
     * @param project the project.
     */
    public void setProject(Project project) {
        this.project = project;
    }

    /**
     * Get the project for this runner.
     * @return the project.
     */
    public Project getProject() {
        return project;
    }

    /**
     * Bind the runner to a project component.
     * Properties, targets and references are all added as beans;
     * project is bound to project, and self to the component.
     * @param component to become <code>self</code>
     */
    public void bindToComponent(ProjectComponent component) {
        project = component.getProject();

        final Map<String, Object> effectiveProperties = new HashMap<>();
        project.getPropertyNames().forEach(n -> effectiveProperties.put(n, project.getProperty(n)));
        addBeans(effectiveProperties);
        addBeans(project.getCopyOfTargets());
        addBeans(project.getCopyOfReferences());
        addBean("project", project);
        addBean("self", component);
    }

    /**
     * Bind the runner to a project component.
     * The project and self are the only beans set.
     * @param component to become <code>self</code>
     */
    public void bindToComponentMinimum(ProjectComponent component) {
        project = component.getProject();
        addBean("project", project);
        addBean("self", component);
    }

    /**
     * Check if the language attribute is set.
     * @throws BuildException if it is not.
     */
    protected void checkLanguage() {
        if (language == null) {
            throw new BuildException("script language must be specified");
        }
    }

    /**
     * Replace the current context classloader with the
     * script context classloader.
     * @return the current context classloader.
     */
    protected ClassLoader replaceContextLoader() {
        ClassLoader origContextClassLoader =
            Thread.currentThread().getContextClassLoader();
        if (getScriptClassLoader() == null) {
            setScriptClassLoader(getClass().getClassLoader());
        }
        Thread.currentThread().setContextClassLoader(getScriptClassLoader());
        return origContextClassLoader;
    }

    /**
     * Restore the context loader with the original context classloader.
     *
     * script context loader.
     * @param origLoader the original context classloader.
     */
    protected void restoreContextLoader(ClassLoader origLoader) {
        Thread.currentThread().setContextClassLoader(
                 origLoader);
    }
}
