//   Copyright 2012 Georg-August-Universität Göttingen, Germany
//
//   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 de.ugoe.cs.util.console;

import java.io.File;
import java.io.FileInputStream;
import java.io.FilenameFilter;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.List;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.jar.JarEntry;
import java.util.jar.JarInputStream;
import java.util.logging.Level;

import de.ugoe.cs.util.StringTools;

/**
 * <p>
 * Executes commands. The commands have to implement the {@link Command} interface and be in
 * packages registered using addCommandPackage(). Additionally, default commands are implemented in
 * the de.ugoe.cs.util.console.defaultcommands package.
 * </p>
 * <p>
 * This class is implemented as a <i>Singleton</i>.
 * </p>
 * 
 * @author Steffen Herbold
 * @version 1.0
 */
public class CommandExecuter {

    /**
     * <p>
     * Handle of the CommandExecuter instance.
     * </p>
     */
    private final static CommandExecuter theInstance = new CommandExecuter();

    /**
     * <p>
     * Prefix of all command classes.
     * </p>
     */
    private static final String cmdPrefix = "CMD";

    /**
     * <p>
     * Name of the package for default commands.
     * </p>
     */
    private static final String defaultPackage = "de.ugoe.cs.util.console.defaultcommands";

    /**
     * <p>
     * List of packages in which commands may be defined. The exec methods trys to load command from
     * these packages in the order they have been added.
     * </p>
     * <p>
     * The de.ugoe.cs.util.console.defaultcommands package has always lowest priority, unless it is
     * specifically added.
     * </p>
     */
    private List<CommandPackage> commandPackageList;

    /**
     * <p>
     * the list of available commands (lazy instantiation in the method
     * {@link #getAvailableCommands()})
     * <p>
     */
    private Command[] availableCommands;

    /**
     * <p>
     * Returns the instance of CommandExecuter. If no instances exists yet, a new one is created.
     * </p>
     * 
     * @return the instance of CommandExecuter
     */
    public static synchronized CommandExecuter getInstance() {
        return theInstance;
    }

    /**
     * <p>
     * Creates a new CommandExecuter. Private to prevent multiple instances (Singleton).
     * </p>
     */
    private CommandExecuter() {
        commandPackageList = new ArrayList<CommandPackage>();
    }

    /**
     * <p>
     * Adds a package that will be used by {@link #exec(String)} to load command from.
     * </p>
     * 
     * @param pkg
     *            package where commands are located
     * @throws IllegalArgumentException
     *             thrown if the package name is null or empty string
     */
    public void addCommandPackage(String pkg, ClassLoader loader) {
        if ("".equals(pkg) || pkg == null) {
            throw new IllegalArgumentException("package name must not be null or empty string");
        }
        commandPackageList.add(new CommandPackage(pkg, loader));
        availableCommands = null;
    }

    /**
     * <p>
     * Executes the command defined by string. A command has the following form (mix of EBNF and
     * natural language):
     * </p>
     * <code>
     * &lt;command&gt; := &lt;commandname&gt;&lt;whitespace&gt;{&lt;parameter&gt;}<br>
     * &lt;commandname&gt; := String without whitespaces. Has to be a valid Java class name<br>
     * &lt;parameter&gt; := &lt;string&gt;|&lt;stringarray&gt;<br>
     * &lt;string&gt; := &lt;stringwithoutwhitespaces&gt;|&lt;stringwithwhitespaces&gt;
     * &lt;stringwithoutwhitespaces&gt; := a string without whitespaces<br>
     * &lt;stringwithoutwhitespaces&gt; := a string, that can have whitespaces, but must be in double quotes<br>
     * &lt;stringarray&gt; := "["&lt;string&gt;{&lt;whitespace&gt;&lt;string&gt;"]"
     * </code>
     * 
     * @param command
     *            the command as a string
     */
    public void exec(String command) {
        Console.commandNotification(command);
        CommandParser parser = new CommandParser();
        parser.parse(command);

        Command cmd = getCMD(parser.getCommandName());

        if (cmd == null) {
            Console.println("Unknown command");
        }
        else {
            try {
                cmd.run(parser.getParameters());
            }
            catch (IllegalArgumentException e) {
                Console.println("invalid parameter provided: " + e.getMessage());
                Console.println("Usage: " + cmd.help());
            }
            catch (Exception e) {
                Console.println("error executing command: " + e);
                Console.logException(e);
                Console.println("Usage: " + cmd.help());
            }
        }
    }

    /**
     * <p>
     * Helper method that loads a class and tries to cast it to {@link Command}.
     * </p>
     * 
     * @param className
     *            qualified name of the class (including package name)
     * @return if class is available and implement {@link Command} and instance of the class, null
     *         otherwise
     */
    public Command getCMD(String commandName) {
        for (Command candidate : getAvailableCommands()) {
            if (candidate.getClass().getSimpleName().equals(cmdPrefix + commandName)) {
                return candidate;
            }
        }

        return null;
    }

    /**
     * <p>
     * reads all available commands from the registered command packages and returns a list of their
     * names
     * </p>
     * 
     * @return an array containing the names of the available commands.
     */
    public Command[] getAvailableCommands() {
        if (availableCommands == null) {
            // List<Command> commands = new ArrayList<Command>();
            List<CommandPackage> packages = new ArrayList<CommandPackage>();
            packages.addAll(commandPackageList);
            packages.add(new CommandPackage(defaultPackage, this.getClass().getClassLoader()));

            FilenameFilter filter = new FilenameFilter() {
                @Override
                public boolean accept(File dir, String name) {
                    return (name != null) && (name.startsWith(cmdPrefix)) &&
                        (name.endsWith(".class"));
                }
            };

            SortedSet<Command> commands = new TreeSet<Command>(new Comparator<Command>() {
                @Override
                public int compare(Command arg1, Command arg2) {
                    String str1 = arg1.getClass().getSimpleName().substring(cmdPrefix.length());
                    String str2 = arg2.getClass().getSimpleName().substring(cmdPrefix.length());
                    return str1.compareTo(str2);
                }

            });

            for (CommandPackage commandPackage : packages) {
                String path = commandPackage.getPackageName().replace('.', '/');
                try {
                    ClassLoader loader = commandPackage.getClassLoader();

                    if (loader == null) {
                        loader = ClassLoader.getSystemClassLoader();
                    }

                    Enumeration<URL> resources = loader.getResources(path);

                    while (resources.hasMoreElements()) {
                        URL resource = resources.nextElement();
                        File packageDir = new File(resource.getFile());

                        if (packageDir.isDirectory()) {
                            File[] classFiles = packageDir.listFiles(filter);
                            if (classFiles != null) {
                                for (File classFile : classFiles) {
                                    String className = classFile.getName()
                                        .substring(0, classFile.getName().lastIndexOf('.'));
                                    
                                    Class<?> clazz =
                                        loader.loadClass(commandPackage.getPackageName() + "." +
                                            className);
                                    
                                    if (Command.class.isAssignableFrom(clazz) &&
                                        clazz.getSimpleName().startsWith(cmdPrefix))
                                    {
                                        commands.add((Command) clazz.getConstructor().newInstance());
                                    }
                                }
                            }
                        }
                        else {
                            int index = resource.getFile().lastIndexOf('!');
                            if ((index > 0) && (resource.getFile().startsWith("file:")) &&
                                (resource.getFile().endsWith("!/" + path)))
                            {
                                String jarFile =
                                    resource.getFile().substring("file:".length(), index);

                                // we have to read the package content from a jar file
                                JarInputStream jarInputStream = null;
                                try {
                                    jarInputStream =
                                        new JarInputStream(new FileInputStream(jarFile));
                                    JarEntry entry = null;
                                    do {
                                        entry = jarInputStream.getNextJarEntry();
                                        if ((entry != null) && (!entry.isDirectory()) &&
                                            (entry.getName().startsWith(path)))
                                        {
                                            String className = entry.getName()
                                                .substring(path.length() + 1,
                                                           entry.getName().lastIndexOf('.'));
                                            
                                            Class<?> clazz =
                                                loader.loadClass(commandPackage.getPackageName() +
                                                    "." + className);
                                            
                                            if (Command.class.isAssignableFrom(clazz) &&
                                                clazz.getSimpleName().startsWith(cmdPrefix))
                                            {
                                                commands.add((Command) clazz.getConstructor().newInstance());
                                            }
                                        }
                                    }
                                    while (entry != null);
                                }
                                finally {
                                    if (jarInputStream != null) {
                                        jarInputStream.close();
                                    }
                                }

                            }
                        }
                    }
                }
                catch (IOException e) {
                    Console.traceln(Level.WARNING, "could not read commands of package " +
                        commandPackage.getPackageName());
                }
                catch (ClassNotFoundException e) {
                    Console.traceln(Level.WARNING, "could not load a command of package " +
                        commandPackage.getPackageName() + ": " + e);
                }
                catch (InstantiationException e) {
                    Console.traceln(Level.WARNING, "could not load a command of package " +
                        commandPackage.getPackageName() + ": " + e);
                }
                catch (IllegalAccessException e) {
                    Console.traceln(Level.WARNING, "could not load a command of package " +
                        commandPackage.getPackageName() + ": " + e);
                }
                catch (IllegalArgumentException e) {
                    Console.traceln(Level.WARNING, "could not load a command of package " +
                        commandPackage.getPackageName() + ": " + e);
                }
                catch (InvocationTargetException e) {
                    Console.traceln(Level.WARNING, "could not load a command of package " +
                        commandPackage.getPackageName() + ": " + e);
                }
                catch (NoSuchMethodException e) {
                    Console.traceln(Level.WARNING, "could not load a command of package " +
                        commandPackage.getPackageName() + ": " + e);
                }
                catch (SecurityException e) {
                    Console.traceln(Level.WARNING, "could not load a command of package " +
                        commandPackage.getPackageName() + ": " + e);
                }
            }

            availableCommands = commands.toArray(new Command[commands.size()]);
        }

        return Arrays.copyOf(availableCommands, availableCommands.length);
    }

    /**
     * <p>
     * Get a copy of the currently registered command packages.
     * </p>
     *
     * @return currently registered command packages
     */
    public List<String> getCommandPackages() {
        List<String> commandPackageListCopy = new ArrayList<>();

        commandPackageListCopy.add(defaultPackage);

        for (CommandPackage pkg : commandPackageList) {
            commandPackageListCopy.add(pkg.getPackageName());
        }

        return commandPackageListCopy;
    }

    /**
     * <p>
     * this method method performs an auto completion of the provided String as far as possible
     * regarding the available commands. It auto completes to the full command name, if only one
     * command matches the given prefix. It auto completes to the common denominator, if several
     * commands match the prefix
     * </p>
     *
     * @param commandPrefix
     *            the prefix to be auto completed
     * 
     * @return as described
     */
    public String autoCompleteCommand(String commandPrefix) {
        Command[] commands = getAvailableCommands();

        String[] completions = new String[commands.length];

        for (int i = 0; i < commands.length; i++) {
            completions[i] = commands[i].getClass().getSimpleName().substring(3);
        }

        return StringTools.autocomplete(commandPrefix, completions);
    }

    /**
     * represents a command package with a package name and the class loader to use
     */
    private class CommandPackage {
        /**
         * the name of the represented package
         */
        private String packageName;

        /**
         * the class loader to use to load the package
         */
        private ClassLoader classLoader;

        /**
         * <p>
         * instantiate the fields
         * </p>
         */
        public CommandPackage(String packageName, ClassLoader classLoader) {
            super();
            this.packageName = packageName;
            this.classLoader = classLoader;
        }

        /**
         * @return the packageName
         */
        public String getPackageName() {
            return packageName;
        }

        /**
         * @return the classLoader
         */
        public ClassLoader getClassLoader() {
            return classLoader;
        }

    }
}
