// Big Screen Bot
//
// Douglas Thrift
//
// BigScreenBot.java

/*  Copyright 2011 Douglas Thrift
 *
 *  This file is part of Big Screen Bot.
 *
 *  Big Screen Bot is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  Big Screen Bot is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with Big Screen Bot.  If not, see <http://www.gnu.org/licenses/>.
 */

package net.douglasthrift.bigscreenbot;

import java.io.IOException;

import java.net.MalformedURLException;
import java.net.UnknownHostException;
import java.net.URL;

import java.security.GeneralSecurityException;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.Set;

import java.util.regex.Pattern;

import javax.net.ssl.SSLSocketFactory;

import com.google.polo.exception.PoloException;

import com.google.polo.ssl.DummySSLSocketFactory;

import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.GnuParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;

import org.apache.commons.lang3.StringUtils;

import org.jibble.pircbot.Colors;
import org.jibble.pircbot.IrcException;

public class BigScreenBot extends Bot
{
    private static final int CHANNEL = 0x1;
    private static final int PRIVATE = 0x2;
    private static final int BOTH    = 0x3;

    private static enum Action { RECONNECT, RESTART, QUIT }

    private abstract class Command
    {
        private boolean admin;
        private int access;
        private String arguments, description;

        protected Command(boolean admin, int access, String arguments, String description)
        {
            this.admin = admin;
            this.access = access;
            this.arguments = arguments;
            this.description = description;
        }

        public abstract void execute(String channel, String sender, boolean admin, String argument);

        public int getAccess()
        {
            return access;
        }

        public String getArguments()
        {
            return arguments;
        }

        public String getDescription()
        {
            return description;
        }

        public boolean isAdmin()
        {
            return admin;
        }

        public boolean isChannel()
        {
            return (access & CHANNEL) != 0;
        }

        public boolean isPrivate()
        {
            return (access & PRIVATE) != 0;
        }
    }

    private boolean verbose;
    private Remote remote;
    private Settings settings = new Settings();
    private Set<String> channels;
    private List<Pattern> admins = new ArrayList<Pattern>();
    private Map<String, Pattern> bans = new TreeMap<String, Pattern>();
    private Map<String, Command> commands = new TreeMap<String, Command>();
    private Action action = Action.RECONNECT;

    private BigScreenBot(boolean verbose)
    {
        super();

        this.verbose = verbose;

        try
        {
            settings.load();

            remote = new Remote(settings);
        }
        catch (IOException exception)
        {
            error(exception, 1);
        }
        catch (GeneralSecurityException exception)
        {
            error(exception, 1);
        }

        setAutoNickChange(true);
        setFinger("Big Screen Bot");
        setMessageDelay(0);
        setVersion(String.format("Big Screen Bot (%1$s)", System.getProperty("os.name")));

        if (settings.getBooleanProperty("ssl", false))
            if (settings.getBooleanProperty("verify", true))
                setSocketFactory(SSLSocketFactory.getDefault());
            else
                try
                {
                    setSocketFactory(DummySSLSocketFactory.fromKeyManagers(null));
                }
                catch (GeneralSecurityException exception)
                {
                    error(exception, 1);
                }

        setLogin(System.getProperty("user.name"));
        setName(settings.getProperty("nick", "bigscreenbot"));
        setVerbose(verbose);

        channels = new HashSet<String>(settings.getListProperty("channels", new ArrayList<String>()));

        for (String admin : settings.getListProperty("admins"))
            admins.add(compileNickMask(admin));

        for (String ban : settings.getListProperty("bans", new ArrayList<String>()))
            bans.put(ban, compileNickMask(ban));

        commands.put("ban", new Command(true, PRIVATE, "[mask...]", "block nick masks from using commands")
        {
            @Override
            public void execute(String channel, String sender, boolean admin, String argument)
            {
                String[] arguments = StringUtils.split(argument);

                if (arguments.length == 0)
                {
                    listBans(channel, sender);

                    return;
                }

                synchronized (bans)
                {
                    for (String ban : arguments)
                        if (bans.put(ban, compileNickMask(ban)) == null)
                            sendMessage(channel, sender, String.format("banned nick mask (\"%1$s\")", ban));
                        else
                            sendMessage(channel, sender, String.format("nick mask (\"%1$s\") already banned", ban));

                    storeBans(channel, sender);
                }
            }
        });
        commands.put("googletv", new Command(false, BOTH, "url [device]", "fling url to a Google TV device")
        {
            @Override
            public void execute(final String channel, final String sender, boolean admin, String argument)
            {
                final String[] arguments = StringUtils.split(argument, null, 2);

                if (arguments.length == 0)
                {
                    help(channel, sender, admin, "googletv");

                    return;
                }

                try
                {
                    if (!new URL(arguments[0]).getProtocol().matches("^https?$"))
                    {
                        invalidURL(channel, sender, arguments[0]);

                        return;
                    }
                }
                catch (MalformedURLException exception)
                {
                    invalidURL(channel, sender, arguments[0]);

                    return;
                }

                if (arguments.length == 2)
                    sendMessage(channel, sender, String.format("flinging URL (\"%1$s\") to device (\"%2$s\")...", arguments[0], arguments[1]));
                else
                    sendMessage(channel, sender, String.format("flinging URL (\"%1$s\") to device(s)...", arguments[0]));

                new Thread()
                {
                    @Override
                    public void run()
                    {
                        synchronized (remote)
                        {
                            if (arguments.length == 2)
                                remote.fling(arguments[1], arguments[0]);
                            else
                                remote.fling(arguments[0]);
                        }

                        sendMessage(channel, sender, String.format("flung URL (\"%1$s\") to device(s)", arguments[0]));
                    }
                }.start();
            }

            private void invalidURL(String channel, String sender, String url)
            {
                sendMessage(channel, sender, String.format("invalid URL (\"%1$s\")", url));
            }
        });
        commands.put("help", new Command(false, PRIVATE, "[command]", "show this help message")
        {
            @Override
            public void execute(String channel, String sender, boolean admin, String arguments)
            {
                String argument = null;
                Command command = null;
 
                try
                {
                    argument = StringUtils.split(arguments, null, 2)[0].toLowerCase();

                    if (argument.startsWith("!"))
                        argument = argument.substring(1);

                    command = commands.get(argument);
                }
                catch (ArrayIndexOutOfBoundsException exception) {}

                sendMessage(channel, sender, Colors.BOLD + String.format("%1$-11s %2$-23s %3$-15s %4$s", "command", "arguments", "access", "description") + Colors.NORMAL);

                if (command != null)
                    help(channel, sender, admin, argument, command);
                else
                    for (Map.Entry<String, Command> nameCommand : commands.entrySet())
                        help(channel, sender, admin, nameCommand.getKey(), nameCommand.getValue());
            }

            private void help(String channel, String sender, boolean admin, String name, Command command)
            {
                boolean unavailable = command.isAdmin() && !admin;
                String access;

                switch (command.getAccess())
                {
                case CHANNEL:
                    access = "channel"; break;
                case PRIVATE:
                    access = "private"; break;
                case BOTH: default:
                    access = "channel/private"; break;
                }

                sendMessage(channel, sender, (unavailable ? Colors.UNDERLINE : "") + String.format("%1$-11s %2$-23s %3$-15s %4$s", name, command.getArguments(), access, command.getDescription()) + (unavailable ? Colors.NORMAL : ""));
            }
        });
        commands.put("join", new Command(true, PRIVATE, "channel", "join a channel")
        {
            @Override
            public void execute(String channel, String sender, boolean admin, String arguments)
            {
                String argument;

                try
                {
                    argument = StringUtils.split(arguments, null, 2)[0];
                }
                catch (ArrayIndexOutOfBoundsException exception)
                {
                    help(channel, sender, admin, "join");

                    return;
                }

                joinChannel(argument);

                synchronized (channels)
                {
                    channels.add(argument);

                    storeChannels(channel, sender);
                }

                sendMessage(channel, sender, String.format("joined channel (\"%1$s\")", argument));
            }
        });
        commands.put("pair", new Command(true, PRIVATE, "[device [code]]", "pair with a Google TV device")
        {
            @Override
            public void execute(final String channel, final String sender, boolean admin, String argument)
            {
                final String[] arguments = StringUtils.split(argument, null, 2);

                switch (arguments.length)
                {
                case 0:
                    sendMessage(channel, sender, "searching for devices to pair with...");

                    new Thread()
                    {
                        @Override
                        public void run()
                        {
                            List<String> devices;

                            synchronized (remote)
                            {
                                devices = remote.listDevices();
                            }

                            if (devices.isEmpty())
                            {
                                sendMessage(channel, sender, "there are no devices to pair with");

                                return;
                            }

                            sendMessage(channel, sender, Colors.BOLD + "devices" + Colors.NORMAL);

                            for (String device : devices)
                                sendMessage(channel, sender, device);
                        }
                    }.start();

                    break;
                case 1:
                    sendMessage(channel, sender, String.format("starting to pair with device (\"%1$s\")...", arguments[0]));

                    new Thread()
                    {
                        @Override
                        public void run()
                        {
                            try
                            {
                                synchronized (remote)
                                {
                                    remote.startPairDevice(arguments[0]);
                                }
                            }
                            catch (MalformedURLException exception)
                            {
                                sendMessage(channel, sender, String.format("invalid device name (\"%1$s\")", arguments[0]));

                                return;
                            }
                            catch (UnknownHostException exception)
                            {
                                sendMessage(channel, sender, String.format("could not find device (\"%1$s\")", arguments[0]));

                                return;
                            }
                            catch (IOException exception)
                            {
                                error(channel, sender, exception);

                                return;
                            }
                            catch (PoloException exception)
                            {
                                error(channel, sender, exception);

                                return;
                            }

                            sendMessage(channel, sender, String.format("enter the code from the device (\"%1$s\") to finish pairing", arguments[0]));
                        }
                    }.start();

                    break;
                default:
                    sendMessage(channel, sender, String.format("finishing pairing with device (\"%1$s\")...", arguments[0]));

                    new Thread()
                    {
                        @Override
                        public void run()
                        {
                            synchronized (remote)
                            {
                                remote.finishPairDevice(arguments[0], arguments[1]);
                            }

                            sendMessage(channel, sender, String.format("paired with device (\"%1$s\")", arguments[0]));
                        }
                    }.start();
                }
            }
        });
        commands.put("part", new Command(true, PRIVATE, "channel [message]", "part from a channel")
        {
            @Override
            public void execute(String channel, String sender, boolean admin, String argument)
            {
                String[] arguments = StringUtils.split(argument, null, 2);

                if (arguments.length == 0)
                {
                    help(channel, sender, admin, "part");

                    return;
                }

                if (arguments.length == 2)
                    partChannel(arguments[0], arguments[1]);
                else
                    partChannel(arguments[0]);

                synchronized (channels)
                {
                    channels.remove(arguments[0]);

                    storeChannels(channel, sender);
                }

                sendMessage(channel, sender, String.format("parted channel (\"%1$s\")", arguments[0]));
            }
        });
        commands.put("quit", new Command(true, PRIVATE, "[message]", "quit and do not come back")
        {
            @Override
            public void execute(String channel, String sender, boolean admin, String argument)
            {
                synchronized (action)
                {
                    action = Action.QUIT;
                }

                quitServer(!argument.isEmpty() ? argument : "oh no!");
            }
        });
        commands.put("restart", new Command(true, PRIVATE, "", "quit and join running more up to date code")
        {
            @Override
            public void execute(String channel, String sender, boolean admin, String argument)
            {
                synchronized (action)
                {
                    action = Action.RESTART;
                }

                quitServer("restarting");
            }
        });
        commands.put("say", new Command(true, PRIVATE, "nick|channel message", "say message to nick or channel")
        {
            @Override
            public void execute(String channel, String sender, boolean admin, String argument)
            {
                String[] arguments = StringUtils.split(argument, null, 2);

                if (arguments.length != 2)
                {
                    help(channel, sender, admin, "say");

                    return;
                }

                if (arguments[0].equalsIgnoreCase(getNick()))
                {
                    sendMessage(channel, sender, "nice try");

                    return;
                }

                sendMessage(arguments[0], arguments[1]);
                sendMessage(channel, sender, String.format("successfully sent message (\"%1$s\") to nick/channel (\"%2$s\")", arguments[1], arguments[0]));
            }
        });
        commands.put("unban", new Command(true, PRIVATE, "[mask...]", "allow blocked nick masks to use commands again")
        {
            @Override
            public void execute(String channel, String sender, boolean admin, String argument)
            {
                String[] arguments = StringUtils.split(argument);

                if (arguments.length == 0)
                {
                    listBans(channel, sender);

                    return;
                }

                synchronized (bans)
                {
                    for (String ban : arguments)
                        if (bans.remove(ban) != null)
                            sendMessage(channel, sender, String.format("unbanned nick mask (\"%1$s\")", ban));
                        else
                            sendMessage(channel, sender, String.format("nick mask (\"%1$s\") already unbanned", ban));

                    storeBans(channel, sender);
                }
            }
        });

        try
        {
            connect(settings.getProperty("server"), settings.getIntegerProperty("port", 6667));
        }
        catch (IOException exception)
        {
            error(exception, 1);
        }
        catch (IrcException exception)
        {
            error(exception, 1);
        }
    }

    public void sendMessage(String channel, String sender, String message)
    {
        sendMessage(channel != null ? channel : sender, message);
    }

    @Override
    protected void onConnect()
    {
        synchronized (channels)
        {
            for (String channel : channels)
                joinChannel(channel);
        }
    }

    @Override
    protected void onDisconnect()
    {
        synchronized (action)
        {
            switch (action)
            {
            case RESTART:
                dispose();

                System.exit(2);
            case QUIT:
                dispose();
                break;
            default:
                while (true)
                {
                    try
                    {
                        reconnect();
                    }
                    catch (IOException exception)
                    {
                        continue;
                    }
                    catch (IrcException exception)
                    {
                        error(exception, 1);
                    }

                    break;
                }
            }
        }
    }

    @Override
    protected void onMessage(String channel, String sender, String login, String hostname, String message)
    {
        doCommandFromMessage(channel, sender, login, hostname, message);
    }

    @Override
    protected void onPrivateMessage(String sender, String login, String hostname, String message)
    {
        doCommandFromMessage(null, sender, login, hostname, message);
    }

    private void doCommandFromMessage(String channel, String sender, String login, String hostname, String message)
    {
        boolean admin = matchNickMasks(sender, login, hostname, admins);

        synchronized (bans)
        {
            if (!admin && (matchNickMasks(sender, login, hostname, bans.values()) || !isNickInChannels(sender)))
                return;
        }

        message = Colors.removeFormattingAndColors(message);

        String[] arguments = StringUtils.split(message, null, 2);
        String argument = "";
        Command command = null;

        if (arguments.length != 0)
        {
            if (channel == null)
                try
                {
                    if (new URL(arguments[0]).getProtocol().matches("^https?$"))
                    {
                        commands.get("googletv").execute(channel, sender, admin, StringUtils.join(arguments, ' '));

                        return;
                    }
                }
                catch (MalformedURLException exception) {}

            argument = arguments[0].toLowerCase();

            if (argument.startsWith("!"))
                argument = argument.substring(1);

            command = commands.get(argument);
        }

        if (channel != null)
        {
            if (command == null)
                return;

            if (!admin && command.isAdmin())
                return;

            if (!command.isChannel())
                return;
        }
        else
        {
            if (command == null)
            {
                sendMessage(channel, sender, String.format("unknown command (\"%1$s\"); try \"help\"", argument));

                return;
            }

            if (!admin && command.isAdmin())
            {
                sendMessage(channel, sender, String.format("unauthorized command (\"%1$s\")", argument));

                return;
            }

            if (!command.isPrivate())
            {
                sendMessage(channel, sender, String.format("inappropriate command (\"%1$s\")", argument));

                return;
            }
        }

        command.execute(channel, sender, admin, arguments.length == 2 ? arguments[1] : "");
    }

    private void error(Exception exception)
    {
        if (verbose)
            exception.printStackTrace();
        else
            System.err.println("bigscreenbot: " + exception.getMessage());
    }

    private void error(Exception exception, int code)
    {
        error(exception);

        System.exit(code);
    }

    private void error(String channel, String sender, Exception exception)
    {
        error(exception);
        sendMessage(channel, sender, "an error occurred");
    }

    private void help(String channel, String sender, boolean admin, String command)
    {
        commands.get("help").execute(channel, sender, admin, command);
    }

    private void listBans(String channel, String sender)
    {
        synchronized (bans)
        {
            if (bans.isEmpty())
            {
                sendMessage(channel, sender, "there are no bans");

                return;
            }

            sendMessage(channel, sender, Colors.BOLD + "ban" + Colors.NORMAL);

            for (String ban : bans.keySet())
                sendMessage(channel, sender, ban);
        }
    }

    private void storeBans(String channel, String sender)
    {
        synchronized (bans)
        {
            synchronized (settings)
            {
                settings.setListProperty("bans", bans.keySet());

                try
                {
                    settings.store();
                }
                catch (IOException exception)
                {
                    error(channel, sender, exception);
                }
            }
        }
    }

    private void storeChannels(String channel, String sender)
    {
        synchronized (channels)
        {
            synchronized (settings)
            {
                settings.setListProperty("channels", channels);

                try
                {
                    settings.store();
                }
                catch (IOException exception)
                {
                    error(channel, sender, exception);
                }
            }
        }
    }

    public static void main(String[] args)
    {
        Options options = new Options();

        options.addOption("h", "help", false, "show this help message and exit");
        options.addOption("v", "verbose", false, "");

        CommandLine line = null;

        try
        {
            line = new GnuParser().parse(options, args);
        }
        catch (ParseException exception)
        {
            System.err.println("bigscreenbot: " + exception.getMessage());
        }

        if (line.hasOption('h'))
        {
            new HelpFormatter().printHelp("bigscreenbot", options, true);

            return;
        }

        new BigScreenBot(line.hasOption('v'));
    }
}

// vim: expandtab
