public class ChatViewerBot extends PircBot
{
    private static final Logger logger = Logger.getLogger(ChatViewerBot.class);

    /**
     * Debugging method to feed captured strings that include Twitch tags into the program
     * 
     * @param filename
     *            Name of a text file containing one tagged IRC post per line
     * @throws Exception
     */
    public void debug(String filename) throws Exception
    {
        File f = new File(filename);
        BufferedReader br = new BufferedReader(new FileReader(f));
        String line;
        while ((line = br.readLine()) != null)
        {
            onUnknown(line);
        }
        br.close();
    }

    /**
     * Separates segments of the post, the parameters, the prefix, and the message itself, for Twitch messages
     */
    private static final String POST_SEPARATOR = " :";

    /**
     * Indicates a Client-to-Client Protocol (CTCP) message if this character is at the start of the message content. There can be an optional terminating character at the end of the message.
     */
    private static final String CTCP_INDICATOR = Character.toString((char) 1);

    /**
     * The base URL for looking up username casing on the Twitch API
     */
    private static final String USERNAME_LOOKUP_BASE_URL = "https://api.twitch.tv/kraken/users/";

    /**
     * Indicates that the disconnect is to be expected, do not attempt to reconnect
     */
    private boolean disconnectExpected;

    /**
     * Reference to the chat panel to add messages as they're posted
     */
    private ChatPanel chat;

    /**
     * The control panel that calls to connect or disconnect the bot, stored as a member to enable or to disable buttons based on successful connections
     */
    private ControlPanelIrc controlPanel;

    /**
     * Used to determine the selected username case resolution type
     */
    private ConfigMessage messageConfig;

    /**
     * A map of name casing where the key is the username in all lowercase, and the value is the correctly cased username
     */
    private Map<String, String> usernameCases;

    /**
     * A map of username values keyed off of IDs, used to identify who was banned when the ban message comes in carrying only the user ID
     */
    private Map<String, String> usernameIds;

    /**
     * Map of Privmsg objects keyed off of a lowercase username. These user states contain the information prepended to each message a user sends to the chat. The user states also contain the post count.
     */
    private Map<String, TwitchPrivmsg> privmsgs;

    /**
     * Default constructor, just initializes the username case map
     */
    public ChatViewerBot()
    {
        this.usernameCases = new HashMap<String, String>();
        this.usernameIds = new HashMap<String, String>();
        this.privmsgs = new HashMap<String, TwitchPrivmsg>();

        final String encoding = "UTF-8";
        try
        {
            this.setEncoding(encoding);
            logger.debug("IRC encoding set to " + encoding);
        }
        catch (Exception e)
        {
            logger.error("Unable to set IRC encoding to " + encoding, e);
        }
    }

    public void reset()
    {
        for (TwitchPrivmsg state : privmsgs.values())
        {
            state.resetPostCount();
        }
        usernameCases.clear();
    }

    @Override
    public void log(String line)
    {
        controlPanel.log(line);
    }

    /**
     * Empty out the cache of usernames. Used when the option of how to case usernames is changed via the Message control panel tab
     */
    public void clearUsernameCases()
    {
        usernameCases.clear();
    }

    /**
     * Set the reference to the chat panel to add messages as they're posted
     * 
     * @param chat
     */
    public void setChatPanel(ChatPanel chat)
    {
        this.chat = chat;
    }

    /**
     * PircBot doesn't let you set the username if you're connected, so this checks for that
     * 
     * @param name
     */
    public void setUsername(String name)
    {
        if (!isConnected())
        {
            super.setName(name);
        }
    }

    /**
     * This method is called once the PircBot has success to the IRC server.
     * <p>
     * The implementation of this method in the PircBot abstract class performs no actions and may be overridden as required.
     * 
     * @since PircBot 0.9.6
     */
    @Override
    protected void onConnect()
    {
        disconnectExpected = false;

        logger.info("Connected");
        controlPanel.toggleConnect(true);

        // Register for Twitch-specific capabilities.
        // Sending this message to a Twitch IRC server will prepend all the user posts with subscriber, emote, and other
        // information. This will prevent the onMessage method from being fired because PircBot no longer recognizes the
        // message. It will instead trigger the onUnknown method.
        sendRawLine("CAP REQ :twitch.tv/membership");
        // Enables USERSTATE, GLOBALUSERSTATE, ROOMSTATE, HOSTTARGET, NOTICE and CLEARCHAT raw commands.
        sendRawLine("CAP REQ :twitch.tv/tags");
        // Enables TIMEOUTS and BANS
        sendRawLine("CAP REQ :twitch.tv/commands");
    }

    /**
     * This method is called whenever someone (possibly us) joins a channel which we are on.
     * <p>
     * The implementation of this method in the PircBot abstract class performs no actions and may be overridden as required.
     *
     * @param channel
     *            The channel which somebody joined.
     * @param sender
     *            The nick of the user who joined the channel.
     * @param login
     *            The login of the user who joined the channel.
     * @param hostname
     *            The hostname of the user who joined the channel.
     */
    @Override
    protected void onJoin(String channel, String sender, String login, String hostname)
    {
        TwitchPrivmsg privmsg = getPrivmsg(sender);
        sendMessageToChat(MessageType.JOIN, "joined " + channel + ".", privmsg);
    }

    /**
     * This method is called whenever an ACTION is sent from a user. E.g. such events generated by typing "/me goes shopping" in most IRC clients.
     * <p>
     * The implementation of this method in the PircBot abstract class performs no actions and may be overridden as required.
     * 
     * @param sender
     *            The nick of the user that sent the action.
     * @param login
     *            The login of the user that sent the action.
     * @param hostname
     *            The hostname of the user that sent the action.
     * @param target
     *            The target of the action, be it a channel or our nick.
     * @param action
     *            The action carried out by the user.
     */
    @Override
    protected void onAction(String sender, String login, String hostname, String target, String action)
    {
        TwitchPrivmsg privmsg = getPrivmsg(sender);
        sendMessageToChat(MessageType.ACTION, action, privmsg);
    }

    private static final String TWITCH_CAP_MESSAGE = ":tmi.twitch.tv CAP * ACK :twitch.tv/";

    private static final String PING_MESSAGE = "PING ";

    /**
     * When the bot registers for Twitch-specific capabilities, the message will be prepended with subscriber, emote, and other information. This will prevent the onMessage method from being fired because PircBot no longer recognizes the
     * message. It will instead trigger this onUnknown method.
     */
    @Override
    protected void handleLine(String response)
    {
        if (response == null)
        {
            return;
        }
        else if (response.startsWith(TWITCH_CAP_MESSAGE))
        {
            return;
        }
        else if (response.startsWith(PING_MESSAGE))
        {
            sendRawLine("PONG " + response.substring(PING_MESSAGE.length()));
        }
        else if (response.startsWith("@"))
        {
            if (response.startsWith("@ban"))
            {
                try
                {
                    int firstBreak = response.indexOf(POST_SEPARATOR);
                    int secondBreak = response.indexOf(POST_SEPARATOR, firstBreak + POST_SEPARATOR.length());

                    Map<String, String> params = parseMessageParams(response, firstBreak, secondBreak);
                    String bannedUserId = params.get("target-user-id");
                    String bannedUsername = usernameIds.get(bannedUserId);
                    String bannedReason = params.get("ban-reason");
                    if (bannedReason == null || bannedReason.trim().isEmpty())
                    {
                        bannedReason = "TWITCH PURGE";
                    }
                    String banDuration = params.get("ban-duration");
                    if (banDuration != null && banDuration.trim().isEmpty())
                    {
                        banDuration = null;
                    }
                    chat.purgeMessagesForUser(bannedUsername, bannedReason + (banDuration == null ? "" : " FOR " + banDuration + " ms"));
                }
                catch (Exception e)
                {
                    log("Unparsable ban: " + response);
                }
            }
            else
            {
                try
                {
                    TwitchPrivmsg privmsg = parseRawTwitchMessage(response);
                    String message = response.substring(response.indexOf(POST_SEPARATOR, response.indexOf(POST_SEPARATOR) + POST_SEPARATOR.length()) + POST_SEPARATOR.length());
                    if (message.startsWith(CTCP_INDICATOR))
                    {
                        // Remove leading character
                        message = message.substring(CTCP_INDICATOR.length());
                        if (message.endsWith(CTCP_INDICATOR))
                        {
                            // Remove terminating character
                            message = message.substring(0, message.length() - 1);
                        }
                        final String commandSplit = " ";
                        if (message.contains(commandSplit))
                        {
                            final String command = message.substring(0, message.indexOf(commandSplit));
                            // Take the command off the message
                            message = message.substring(message.indexOf(commandSplit) + commandSplit.length());
                            if ("ACTION".equals(command))
                            {
                                sendMessageToChat(MessageType.ACTION, message, privmsg);
                            }
                            else
                            {
                                log("Unknown CTCP command: " + command);
                            }
                        }
                        else
                        {
                            log("CTCP message missing command type: " + message);
                        }
                    }
                    else if (privmsg.isDisplayMessage())
                    {
                        sendMessageToChat(MessageType.NORMAL, message, privmsg);
                    }
                }
                catch (Exception e)
                {
                    log("Unparsable message: " + response);
                }
            }
        }
        else
        {
            log(response);
        }
    }

    /**
     * Get the TwitchPrivmsg object from the map, or add a newly instantiated one to the map and return it
     * 
     * @param sender
     * @return privmsg
     */
    private TwitchPrivmsg getPrivmsg(String sender)
    {
        TwitchPrivmsg privmsg = privmsgs.get(sender.toLowerCase());
        if (privmsg == null)
        {
            privmsg = new TwitchPrivmsg(sender);
            privmsgs.put(sender.toLowerCase(), privmsg);
        }
        return privmsg;
    }

    /**
     * Turn a raw message post containing the Twitch header information into a TwitchPrivmsg object
     * 
     * @param rawMessage
     * @return privmsg
     */
    private TwitchPrivmsg parseRawTwitchMessage(String rawMessage)
    {
        TwitchPrivmsg privmsg = new TwitchPrivmsg();

        int firstBreak = rawMessage.indexOf(POST_SEPARATOR);
        int secondBreak = rawMessage.indexOf(POST_SEPARATOR, firstBreak + POST_SEPARATOR.length());

        Map<String, String> paramMap = parseMessageParams(rawMessage, firstBreak, secondBreak);

        String messageClassification = null;
        try
        {
            String mc = rawMessage.substring(firstBreak, secondBreak).trim();
            messageClassification = mc.split(" ")[1].trim();
        }
        catch (Exception e)
        {
            logger.debug("Unable to determine message classification for " + rawMessage, e);
            messageClassification = null;
        }
        privmsg.setMessageClassification(messageClassification);

        String colorStr = paramMap.get("color");
        if (colorStr != null && !colorStr.trim().isEmpty())
        {
            try
            {
                final String hexString = colorStr.substring((colorStr.startsWith("#") ? 1 : 0) + (colorStr.startsWith("0x") ? 2 : 0));
                Color color = new Color(Integer.parseInt(hexString, 16));
                privmsg.setColor(color);
            }
            catch (Exception e)
            {
                logger.trace("Unable to parse color from Twitch", e);
            }
        }
        String displayName = paramMap.get("display-name");
        if (displayName != null && !displayName.trim().isEmpty())
        {
            privmsg.setDisplayName(displayName);
            String userIdStr = paramMap.get("user-id");
            if (userIdStr != null)
            {
                usernameIds.put(userIdStr, displayName.toLowerCase());
            }
        }
        String emotesStr = paramMap.get("emotes");
        if (emotesStr != null && !emotesStr.trim().isEmpty())
        {
            try
            {
                for (String eaiStr : emotesStr.split("/"))
                {
                    String[] idIndexSplit = eaiStr.split(":");
                    Integer emoteId = "null".equals(idIndexSplit[0]) ? null : Integer.parseInt(idIndexSplit[0]);
                    final String indices = idIndexSplit[1];
                    String[] indicesStr = indices.split(",");
                    for (int i = 0; i < indicesStr.length; i++)
                    {
                        String[] begEndSplit = indicesStr[i].split("-");

                        int beg = Integer.parseInt(begEndSplit[0]);
                        int end = Integer.parseInt(begEndSplit[1]);

                        EmoteAndIndices eai = new EmoteAndIndices(emoteId, beg, end);
                        privmsg.addEmote(eai);
                    }
                }
            }
            catch (Exception e)
            {
                logger.error("Something went wrong parsing the message emote data: " + emotesStr, e);
            }
        }
        String subStr = paramMap.get("subscriber");
        if (subStr != null && !subStr.trim().isEmpty())
        {
            try
            {
                int subVal = Integer.parseInt(subStr);
                privmsg.setSubscriber(subVal > 0);
            }
            catch (Exception e)
            {
                log("Error parsing subscriber value \"" + subStr + "\" in Twitch header");
            }
        }
        String turboStr = paramMap.get("turbo");
        if (turboStr != null && !turboStr.trim().isEmpty())
        {
            try
            {
                int turboVal = Integer.parseInt(turboStr);
                privmsg.setTurbo(turboVal > 0);
            }
            catch (Exception e)
            {
                log("Error parsing subscriber value \"" + turboStr + "\" in Twitch header");
            }
        }
        privmsg.setPrime(paramMap.get("badges") != null && paramMap.get("badges").contains("premium"));

        String userTypeStr = paramMap.get("user-type");
        if (displayName != null && !displayName.trim().isEmpty() && displayName.equalsIgnoreCase(this.controlPanel.getChannelNoHash()))
        {
            // Set the broadcaster badge based on the display name matching the channel connected to, since Twitch
            // doesn't put this usertype into its IRC tags.
            privmsg.setUserType(UserType.BROADCASTER);
        }
        else if (userTypeStr != null && !userTypeStr.trim().isEmpty())
        {
            // If the string value is something weird, the enum will just return NONE
            privmsg.setUserType(UserType.getByKey(userTypeStr));
        }

        // message prefix: <servername> | <nick> [ '!' <user> ] [ '@' <host> ]
        String prefix = rawMessage.substring(firstBreak + POST_SEPARATOR.length(), secondBreak);

        if (displayName == null || displayName.trim().isEmpty())
        {
            privmsg.setDisplayName(prefix.substring(0, prefix.indexOf("!")));
        }

        return privmsg;
    }

    private Map<String, String> parseMessageParams(String rawMessage, int firstBreak, int secondBreak)
    {
        // Custom Twitch message parameters:
        final int startIndex = rawMessage.length() > 1 && rawMessage.charAt(0) == '@' ? 1 : 0;
        String[] params = rawMessage.substring(startIndex, firstBreak).split(";");
        Map<String, String> paramMap = new HashMap<String, String>();
        final String paramSplitter = "=";

        for (int i = 0; i < params.length; i++)
        {
            String[] paramHalves = params[i].split(paramSplitter);
            final String key = paramHalves[0];
            String value = "";
            for (int v = 1; v < paramHalves.length; v++)
            {
                value += paramHalves[v] + (v > 1 ? paramSplitter : "");
            }
            paramMap.put(key, value);
        }

        return paramMap;
    }

    /**
     * Get the TwitchPrivmsg for a given sender and or raw message. This method processes the Twitch header text of a message and turns it into a TwitchPrivmsg object.
     * 
     * @param sender
     *            The username of the user who sent the message. This value will be used in lowercase as the key, and it will also be used to construct a TwitchPrivmsg object, so if the casing is incorrect, it will need to be fixed before
     *            the message is displayed.
     * @param rawMessage
     *            Can be either the raw message for parsing into a user state, or null
     * @return
     */
    private TwitchPrivmsg getPrivmsg(String sender, String rawMessage)
    {
        sender = sender == null ? null : sender.toLowerCase();
        TwitchPrivmsg privmsg = privmsgs.get(sender.toLowerCase());
        if (privmsg == null)
        {
            privmsg = new TwitchPrivmsg(sender);
            if (rawMessage != null)
            {
            }
            privmsgs.put(sender.toLowerCase(), privmsg);
        }
        return privmsg;
    }

    /**
     * This method is called whenever a message is sent to a channel.
     * <p>
     * The implementation of this method in the PircBot abstract class performs no actions and may be overridden as required.
     *
     * @param channel
     *            The channel to which the message was sent.
     * @param sender
     *            The nick of the person who sent the message.
     * @param login
     *            The login of the person who sent the message.
     * @param hostname
     *            The hostname of the person who sent the message.
     * @param message
     *            The actual message sent to the channel.
     */
    @Override
    protected void onMessage(String channel, String sender, String login, String hostname, String message)
    {
        TwitchPrivmsg privmsg = getPrivmsg(sender, null);
        sendMessageToChat(message, privmsg);
    }

    /**
     * Post a message to chat with just a username and message content, which defaults to a NORMAL type message
     * 
     * @param username
     * @param message
     * @param privmsg
     */
    public void sendMessageToChat(String message, TwitchPrivmsg privmsg)
    {
        sendMessageToChat(MessageType.NORMAL, message, privmsg);
    }

    /**
     * Post a message to chat, specifying the message type, username, and message content
     * 
     * @param type
     * @param username
     * @param message
     * @param privmsg
     */
    public void sendMessageToChat(MessageType type, String message, TwitchPrivmsg privmsg)
    {
        String casedUsername = handleUsernameCasing(type, privmsg.getDisplayName(), message);

        // Update privmsg with name and post count increment
        privmsg.setDisplayName(casedUsername);
        privmsg.incrementPostCount();

        // Finally, construct the message and send it on to the chat display
        Message msg = new Message(type, casedUsername, message, privmsg);
        chat.addMessage(msg);
    }

    /**
     * Handles custom username casing
     * 
     * @param type
     * @param username
     * @param message
     * @return the appropriately cased username
     */
    private String handleUsernameCasing(MessageType type, String username, String message)
    {
        String casedUsername = username;
        final String lowerCaseUsername = username.toLowerCase();

        if (type != MessageType.JOIN)
        {
            if (messageConfig.isSpecifyCaseAllowed())
            {
                if (message.toLowerCase().contains(lowerCaseUsername))
                {
                    // Run a quick regex find to make sure the username is not inside another word
                    Pattern pat = Pattern.compile("\\b" + lowerCaseUsername + "\\b");
                    Matcher mtch = pat.matcher(message.toLowerCase());
                    if (mtch.find())
                    {
                        final int usernameIndex = message.toLowerCase().indexOf(lowerCaseUsername);
                        casedUsername = message.substring(usernameIndex, usernameIndex + username.length());
                        usernameCases.put(lowerCaseUsername, casedUsername);
                    }
                }
            }

            if (!usernameCases.containsKey(lowerCaseUsername))
            {
                switch (messageConfig.getCaseResolutionType())
                {
                case ALL_CAPS:
                    casedUsername = username.toUpperCase();
                    break;
                case ALL_LOWERCASE:
                    casedUsername = lowerCaseUsername;
                    break;
                case FIRST:
                    casedUsername = username.substring(0, 1).toUpperCase() + username.substring(1).toLowerCase();
                    break;
                case LOOKUP:
                    if (type.containsParsableUsername())
                    {
                        try
                        {
                            URL url = new URL(USERNAME_LOOKUP_BASE_URL + lowerCaseUsername);
                            URLConnection conn = url.openConnection();
                            BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()));

                            String jsonResult = "";
                            String line;
                            while ((line = br.readLine()) != null)
                            {
                                jsonResult += line;
                            }
                            br.close();

                            casedUsername = (String) new Gson().fromJson(jsonResult, Map.class).get("display_name");
                        }
                        catch (Exception e)
                        {
                            logger.debug("Attempt to look up " + username + " on Twitch API failed.");
                        }
                    }
                    break;
                case NONE:
                default:
                    casedUsername = username;
                    break;
                }
                usernameCases.put(lowerCaseUsername, casedUsername);
            }

            if (usernameCases.containsKey(lowerCaseUsername))
            {
                casedUsername = usernameCases.get(lowerCaseUsername);
            }
        }

        return casedUsername;
    }

    /**
     * This method is called whenever a private message is sent to the PircBot.
     * <p>
     * The implementation of this method in the PircBot abstract class performs no actions and may be overridden as required.
     *
     * @param sender
     *            The nick of the person who sent the private message.
     * @param login
     *            The login of the person who sent the private message.
     * @param hostname
     *            The hostname of the person who sent the private message.
     * @param message
     *            The actual message.
     */
    @Override
    protected void onPrivateMessage(String sender, String login, String hostname, String message)
    {
        logger.info("Private message from " + sender + "(" + login + "@" + hostname + "): " + message);
    }

    @Override
    protected void onSetChannelBan(String channel, String sourceNick, String sourceLogin, String sourceHostname, String hostmask)
    {
        chat.banUser(hostmask);
    }

    @Override
    protected void onRemoveChannelBan(String channel, String sourceNick, String sourceLogin, String sourceHostname, String hostmask)
    {
        chat.unbanUser(hostmask);
    }

    /**
     * This method carries out the actions to be performed when the PircBot gets disconnected. This may happen if the PircBot quits from the server, or if the connection is unexpectedly lost.
     * <p>
     * Disconnection from the IRC server is detected immediately if either we or the server close the connection normally. If the connection to the server is lost, but neither we nor the server have explicitly closed the connection, then it
     * may take a few minutes to detect (this is commonly referred to as a "ping timeout").
     * <p>
     * If you wish to get your IRC bot to automatically rejoin a server after the connection has been lost, then this is probably the ideal method to override to implement such functionality.
     * <p>
     * The implementation of this method in the PircBot abstract class performs no actions and may be overridden as required.
     */
    @Override
    protected void onDisconnect()
    {
        logger.info("Disconnected");
        controlPanel.toggleConnect(false);

        // Attempt to reconnect if this is an unexpected disconnect and auto reconnect is enabled
        while (!disconnectExpected && controlPanel.isAutoReconnect() && !isConnected())
        {
            try
            {
                logger.info("Attempting to reconnect");
                reconnect();
                logger.info("Attempting to rejoin channel");
                controlPanel.joinChannel();
            }
            catch (Exception e)
            {
                logger.error("Error reconnecting", e);
            }

            try
            {
                Thread.sleep(250L);
            }
            catch (Exception e)
            {
                logger.trace("Reconnect attempt sleep exception");
            }
        }

        disconnectExpected = false;
    }

    /**
     * Set a reference to the control panel to update the connect/disconnect button text and log events on the Connection tab
     * 
     * @param controlPanel
     */
    public void setControlPanel(ControlPanelIrc controlPanel)
    {
        this.controlPanel = controlPanel;
    }

    /**
     * Set a reference to the message config, used to determine the current username casing resolution type
     * 
     * @param messageConfig
     */
    public void setMessageConfig(ConfigMessage messageConfig)
    {
        this.messageConfig = messageConfig;
    }

    public void setDisconnectExpected(boolean disconnectExpected)
    {
        this.disconnectExpected = disconnectExpected;
    }
} public class ConfigEmoji extends Config
{
    private static final Logger logger = Logger.getLogger(ConfigEmoji.class);

    public static final int MIN_SCALE = 10;
    public static final int MAX_SCALE = 500;

    public static final int MIN_BADGE_OFFSET = -32;
    public static final int MAX_BADGE_OFFSET = 64;

    /**
     * The green of moderator badges, used for FrankerFaceZ badge coloration
     */
    public static final Color MOD_BADGE_COLOR = new Color(0x34AE0A);

    /**
     * Whether all emoji are enabled or not
     */
    private Boolean emojiEnabled;

    /**
     * Whether emoji that have animations should display animated
     */
    private Boolean animationEnabled;

    /**
     * Whether Twitch badges are enabled or not
     */
    private Boolean twitchBadgesEnabled;

    /**
     * Whether FrankerFaceZ badges are enabled or not
     */
    private Boolean ffzBadgesEnabled;

    /**
     * Whether the emoji scale value is used to modify the size of the emoji relative to the line height, or the
     * original emoji image size
     */
    private Boolean emojiScaleToLine;

    /**
     * Whether the badge scale value is used to modify the size of the badge relative to the line height, or the
     * original badge image size
     */
    private Boolean badgeScaleToLine;

    /**
     * This is the number of pixels to offset the badges vertically to accommodate the fact that sprite fonts don't have
     * a baseline to determine where the vertical center of the text is appropriately
     */
    private Integer badgeHeightOffset;

    /**
     * The scale value to be applied to the emoji
     */
    private Integer emojiScale;

    /**
     * The scale value to be applied to the badge
     */
    private Integer badgeScale;

    /**
     * The strategy for drawing an emoji when the emoji key word and image URL are loaded, but the image itself is not
     */
    private EmojiLoadingDisplayStragegy displayStrategy;

    /**
     * Whether Twitch emotes are enabled or not
     */
    private Boolean twitchEnabled;

    /**
     * Whether Twitch global emotes are to be cached or not
     */
    private Boolean twitchCacheEnabled;

    /**
     * Whether FrankerFaceZ emotes are enabled or not
     */
    private Boolean ffzEnabled;

    /**
     * Whether FrankerFaceZ emotes are to be cached or not
     */
    private Boolean ffzCacheEnabled;

    /**
     * The loaded Twitch badges channel, or null if none is loaded
     */
    private String twitchBadgesLoadedChannel;

    /**
     * The loaded FrankerFaceZ badges channel, or null if none is loaded
     */
    private String ffzBadgesLoadedChannel;

    /**
     * Whether the Twitch emotes have been loaded
     */
    private Boolean twitchLoaded;

    /**
     * Whether Twitch global emotes are cached or not
     */
    private Boolean twitchCached;

    /**
     * The loaded FrankerFaceZ channel, or null if none is loaded
     */
    private String ffzLoadedChannel;

    /**
     * Whether the FrankerFaceZ global emotes are loaded
     */
    private Boolean ffzGlobalLoaded;

    /**
     * Whether FrankerFaceZ global and channel specific emotes have been cached
     */
    private Boolean ffzCached;

    /**
     * Whether BetterTTV emotes are enabled or not
     */
    private Boolean bttvEnabled;

    /**
     * Whether BetterTTV emotes are to be cached or not
     */
    private Boolean bttvCacheEnabled;

    /**
     * The loaded BetterTTV channel, or null if none is loaded
     */
    private String bttvLoadedChannel;

    /**
     * Whether the BetterTTV global emotes are loaded
     */
    private Boolean bttvGlobalLoaded;

    /**
     * Whether BetterTTV global and channel specific emotes have been cached
     */
    private Boolean bttvCached;

    /**
     * Whether to use Twitter emoji images rather than the system font
     */
    private Boolean twitterEnabled;

    public ConfigEmoji()
    {
        resetWorkCompleted();
    }

    @Override
    public void reset()
    {
        emojiEnabled = null;
        animationEnabled = null;
        twitchBadgesEnabled = null;
        ffzBadgesEnabled = null;
        emojiScaleToLine = null;
        badgeScaleToLine = null;
        badgeHeightOffset = null;
        emojiScale = null;
        badgeScale = null;
        displayStrategy = null;
        twitchEnabled = null;
        twitchCacheEnabled = null;
        ffzEnabled = null;
        ffzCacheEnabled = null;
        bttvEnabled = null;
        bttvCacheEnabled = null;
        bttvLoadedChannel = null;
        bttvGlobalLoaded = null;
        bttvCached = null;
        twitterEnabled = null;
    }

    public boolean isEmojiEnabled()
    {
        return emojiEnabled != null && emojiEnabled;
    }

    public void setEmojiEnabled(Boolean emojiEnabled)
    {
        this.emojiEnabled = emojiEnabled;
        props.setProperty(FontificatorProperties.KEY_EMOJI_ENABLED, Boolean.toString(emojiEnabled));
    }

    public boolean isAnimationEnabled()
    {
        return animationEnabled != null && animationEnabled;
    }

    public void setAnimationEnabled(Boolean animationEnabled)
    {
        this.animationEnabled = animationEnabled;
        props.setProperty(FontificatorProperties.KEY_EMOJI_ANIMATION, Boolean.toString(animationEnabled));
    }

    public boolean isTwitchBadgesEnabled()
    {
        return twitchBadgesEnabled != null && twitchBadgesEnabled;
    }

    public void setTwitchBadgesEnabled(Boolean twitchBadgesEnabled)
    {
        this.twitchBadgesEnabled = twitchBadgesEnabled;
        props.setProperty(FontificatorProperties.KEY_EMOJI_TWITCH_BADGES, Boolean.toString(twitchBadgesEnabled));
    }

    public boolean isFfzBadgesEnabled()
    {
        return ffzBadgesEnabled != null && ffzBadgesEnabled;
    }

    public void setFfzBadgesEnabled(Boolean ffzBadgesEnabled)
    {
        this.ffzBadgesEnabled = ffzBadgesEnabled;
        props.setProperty(FontificatorProperties.KEY_EMOJI_FFZ_BADGES, Boolean.toString(ffzBadgesEnabled));
    }

    /**
     * Get whether badges of any type enabled
     * 
     * @return ffz or twitch badges enabled
     */
    public boolean isAnyBadgesEnabled()
    {
        return isFfzBadgesEnabled() || isTwitchBadgesEnabled();
    }

    public boolean isEmojiScaleToLine()
    {
        return emojiScaleToLine != null && emojiScaleToLine;
    }

    public void setEmojiScaleToLine(Boolean emojiScaleToLine)
    {
        this.emojiScaleToLine = emojiScaleToLine;
        props.setProperty(FontificatorProperties.KEY_EMOJI_SCALE_TO_LINE, Boolean.toString(emojiScaleToLine));
    }

    public boolean isBadgeScaleToLine()
    {
        return badgeScaleToLine != null && badgeScaleToLine;
    }

    public void setBadgeScaleToLine(Boolean badgeScaleToLine)
    {
        this.badgeScaleToLine = badgeScaleToLine;
        props.setProperty(FontificatorProperties.KEY_EMOJI_BADGE_SCALE_TO_LINE, Boolean.toString(badgeScaleToLine));
    }

    public int getBadgeHeightOffset()
    {
        return badgeHeightOffset;
    }

    public void setBadgeHeightOffset(int badgeHeightOffset)
    {
        this.badgeHeightOffset = badgeHeightOffset;
        props.setProperty(FontificatorProperties.KEY_EMOJI_BADGE_HEIGHT_OFFSET, Integer.toString(badgeHeightOffset));
    }

    public Integer getEmojiScale()
    {
        return emojiScale;
    }

    public void setEmojiScale(Integer emojiScale)
    {
        this.emojiScale = emojiScale;
        props.setProperty(FontificatorProperties.KEY_EMOJI_SCALE, Integer.toString(emojiScale));
    }

    public Integer getBadgeScale()
    {
        return badgeScale;
    }

    public void setBadgeScale(Integer badgeScale)
    {
        this.badgeScale = badgeScale;
        props.setProperty(FontificatorProperties.KEY_EMOJI_BADGE_SCALE, Integer.toString(badgeScale));
    }

    public EmojiLoadingDisplayStragegy getDisplayStrategy()
    {
        return displayStrategy;
    }

    public void setDisplayStrategy(EmojiLoadingDisplayStragegy displayStrategy)
    {
        this.displayStrategy = displayStrategy;
        props.setProperty(FontificatorProperties.KEY_EMOJI_DISPLAY_STRAT, displayStrategy.name());
    }

    public boolean isTwitchEnabled()
    {
        return twitchEnabled != null && twitchEnabled;
    }

    public void setTwitchEnabled(Boolean twitchEnabled)
    {
        this.twitchEnabled = twitchEnabled;
        props.setProperty(FontificatorProperties.KEY_EMOJI_TWITCH_ENABLE, Boolean.toString(twitchEnabled));
    }

    public boolean isTwitchCacheEnabled()
    {
        return twitchCacheEnabled != null && twitchCacheEnabled;
    }

    public void setTwitchCacheEnabled(Boolean twitchCacheEnabled)
    {
        this.twitchCacheEnabled = twitchCacheEnabled;
        props.setProperty(FontificatorProperties.KEY_EMOJI_TWITCH_CACHE, Boolean.toString(twitchCacheEnabled));
    }

    public boolean isFfzEnabled()
    {
        return ffzEnabled != null && ffzEnabled;
    }

    public void setFfzEnabled(Boolean ffzEnabled)
    {
        this.ffzEnabled = ffzEnabled;
        props.setProperty(FontificatorProperties.KEY_EMOJI_FFZ_ENABLE, Boolean.toString(ffzEnabled));
    }

    public boolean isFfzCacheEnabled()
    {
        return ffzCacheEnabled != null && ffzCacheEnabled;
    }

    public void setFfzCacheEnabled(Boolean ffzCacheEnabled)
    {
        this.ffzCacheEnabled = ffzCacheEnabled;
        props.setProperty(FontificatorProperties.KEY_EMOJI_FFZ_CACHE, Boolean.toString(ffzCacheEnabled));
    }

    public Boolean isBttvEnabled()
    {
        return bttvEnabled != null && bttvEnabled;
    }

    public Boolean isBttvCacheEnabled()
    {
        return bttvCacheEnabled != null && bttvCacheEnabled;
    }

    public void setBttvCacheEnabled(Boolean bttvCacheEnabled)
    {
        this.bttvCacheEnabled = bttvCacheEnabled;
        props.setProperty(FontificatorProperties.KEY_EMOJI_BTTV_CACHE, Boolean.toString(bttvCacheEnabled));
    }

    /**
     * Get whether the type of emoji is enabled and loaded
     * 
     * @param type
     * @return enabled
     */
    public boolean isTypeEnabledAndLoaded(EmojiType type)
    {
        if (type == null)
        {
            return false;
        }
        else
        {
            switch (type)
            {
            case FRANKERFACEZ_CHANNEL:
                return ffzEnabled != null && ffzEnabled && ffzLoadedChannel != null;
            case FRANKERFACEZ_GLOBAL:
                return ffzEnabled != null && ffzEnabled && ffzGlobalLoaded != null && ffzGlobalLoaded;
            case FRANKERFACEZ_BADGE:
                return ffzBadgesEnabled != null && ffzBadgesEnabled && ffzBadgesLoadedChannel != null;
            case BETTER_TTV_CHANNEL:
                return bttvEnabled != null && bttvEnabled && bttvLoadedChannel != null;
            case BETTER_TTV_GLOBAL:
                return bttvEnabled != null && bttvEnabled && bttvGlobalLoaded != null && bttvGlobalLoaded;
            // case TWITCH_V2:
            // case TWITCH_V3:
            //     // Only V2 and V3. Chat V1 doesn't use the normal EmojiTypeMap, so it doesn't need to be checked. They're loaded on the fly.
            //     return ControlPanelEmoji.TWITCH_EMOTE_VERSION.equals(type) && twitchEnabled != null && twitchEnabled && twitchLoaded != null && twitchLoaded;
            case TWITCH_BADGE:
                return twitchBadgesEnabled != null && twitchBadgesEnabled && twitchBadgesLoadedChannel != null;
            case TWITTER_EMOJI:
                return twitterEnabled;
            default:
                // If it doesn't have a coded EmojiType, then we don't know it
                return false;
            }
        }
    }

    public LoadConfigReport validateStrings(LoadConfigReport report, String enabledBool, String aniBool, String badgeTwitchBool, String badgeFfzBool, String scaleEnabledBool, String scaleBadgeEnabledBool, String badgeHeightOffsetStr, String scale, String scaleBadge, String displayStrat, String twitchBool, String twitchCacheBool, String ffzBool, String ffzCacheBool, String bttvBool, String bttvCacheBool, String twitterBool)
    {
        validateBooleanStrings(report, enabledBool, aniBool, badgeTwitchBool, badgeFfzBool, scaleEnabledBool, scaleBadgeEnabledBool, twitchBool, twitchCacheBool, ffzBool, ffzCacheBool, bttvBool, bttvCacheBool);
        validateIntegerWithLimitString(FontificatorProperties.KEY_EMOJI_SCALE, scale, MIN_SCALE, MAX_SCALE, report);
        validateIntegerWithLimitString(FontificatorProperties.KEY_EMOJI_BADGE_SCALE, scaleBadge, MIN_SCALE, MAX_SCALE, report);
        validateIntegerWithLimitString(FontificatorProperties.KEY_EMOJI_BADGE_HEIGHT_OFFSET, badgeHeightOffsetStr, MIN_BADGE_OFFSET, MAX_BADGE_OFFSET, report);
        return report;
    }

    @Override
    public LoadConfigReport load(Properties props, LoadConfigReport report)
    {
        this.props = props;

        reset();

        // Check that the values exist
        baseValidation(props, FontificatorProperties.EMOJI_KEYS, report);

        if (report.isErrorFree())
        {
            final String enabledStr = props.getProperty(FontificatorProperties.KEY_EMOJI_ENABLED);
            final String aniStr = props.getProperty(FontificatorProperties.KEY_EMOJI_ANIMATION);

            final String twitchBadgeStr = props.getProperty(FontificatorProperties.KEY_EMOJI_TWITCH_BADGES);
            final String ffzBadgeStr = props.getProperty(FontificatorProperties.KEY_EMOJI_FFZ_BADGES);

            final String scaleEnabledStr = props.getProperty(FontificatorProperties.KEY_EMOJI_SCALE_TO_LINE);
            final String scaleBadgeEnabledStr = props.getProperty(FontificatorProperties.KEY_EMOJI_BADGE_SCALE_TO_LINE);
            final String badgeHeightOffsetStr = props.getProperty(FontificatorProperties.KEY_EMOJI_BADGE_HEIGHT_OFFSET);
            final String scaleStr = props.getProperty(FontificatorProperties.KEY_EMOJI_SCALE);
            final String scaleBadgeStr = props.getProperty(FontificatorProperties.KEY_EMOJI_BADGE_SCALE);
            final String displayStratStr = props.getProperty(FontificatorProperties.KEY_EMOJI_DISPLAY_STRAT);

            final String twitchEnabledStr = props.getProperty(FontificatorProperties.KEY_EMOJI_TWITCH_ENABLE);
            final String twitchCacheStr = props.getProperty(FontificatorProperties.KEY_EMOJI_TWITCH_CACHE);

            final String ffzEnabledStr = props.getProperty(FontificatorProperties.KEY_EMOJI_FFZ_ENABLE);
            final String ffzCacheStr = props.getProperty(FontificatorProperties.KEY_EMOJI_FFZ_CACHE);

            final String bttvEnabledStr = props.getProperty(FontificatorProperties.KEY_EMOJI_BTTV_ENABLE);
            final String bttvCacheStr = props.getProperty(FontificatorProperties.KEY_EMOJI_BTTV_CACHE);

            final String twitterStr = props.getProperty(FontificatorProperties.KEY_EMOJI_TWITTER_ENABLE);

            // Check that the values are valid
            validateStrings(report, enabledStr, aniStr, twitchBadgeStr, ffzBadgeStr, scaleEnabledStr, scaleBadgeEnabledStr, badgeHeightOffsetStr, scaleStr, scaleBadgeStr, displayStratStr, twitchEnabledStr, twitchCacheStr, ffzEnabledStr, ffzCacheStr, bttvEnabledStr, bttvCacheStr, twitterStr);

            // Fill the values
            if (report.isErrorFree())
            {
                emojiEnabled = evaluateBooleanString(props, FontificatorProperties.KEY_EMOJI_ENABLED, report);
                animationEnabled = evaluateBooleanString(props, FontificatorProperties.KEY_EMOJI_ANIMATION, report);
                twitchBadgesEnabled = evaluateBooleanString(props, FontificatorProperties.KEY_EMOJI_TWITCH_BADGES, report);
                ffzBadgesEnabled = evaluateBooleanString(props, FontificatorProperties.KEY_EMOJI_FFZ_BADGES, report);
                emojiScaleToLine = evaluateBooleanString(props, FontificatorProperties.KEY_EMOJI_SCALE_TO_LINE, report);
                badgeScaleToLine = evaluateBooleanString(props, FontificatorProperties.KEY_EMOJI_BADGE_SCALE_TO_LINE, report);
                badgeHeightOffset = evaluateIntegerString(props, FontificatorProperties.KEY_EMOJI_BADGE_HEIGHT_OFFSET, report);
                emojiScale = evaluateIntegerString(props, FontificatorProperties.KEY_EMOJI_SCALE, report);
                badgeScale = evaluateIntegerString(props, FontificatorProperties.KEY_EMOJI_BADGE_SCALE, report);
                displayStrategy = EmojiLoadingDisplayStragegy.valueOf(displayStratStr);
                twitchEnabled = evaluateBooleanString(props, FontificatorProperties.KEY_EMOJI_TWITCH_ENABLE, report);
                twitchCacheEnabled = evaluateBooleanString(props, FontificatorProperties.KEY_EMOJI_TWITCH_CACHE, report);
                ffzEnabled = evaluateBooleanString(props, FontificatorProperties.KEY_EMOJI_FFZ_ENABLE, report);
                ffzCacheEnabled = evaluateBooleanString(props, FontificatorProperties.KEY_EMOJI_FFZ_CACHE, report);
                bttvEnabled = evaluateBooleanString(props, FontificatorProperties.KEY_EMOJI_BTTV_ENABLE, report);
                bttvCacheEnabled = evaluateBooleanString(props, FontificatorProperties.KEY_EMOJI_BTTV_CACHE, report);
                twitterEnabled = evaluateBooleanString(props, FontificatorProperties.KEY_EMOJI_TWITTER_ENABLE, report);
            }
        }

        return report;
    }

    @Override
    public int hashCode()
    {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((displayStrategy == null) ? 0 : displayStrategy.hashCode());
        result = prime * result + ((emojiEnabled == null) ? 0 : emojiEnabled.hashCode());
        result = prime * result + ((animationEnabled == null) ? 0 : animationEnabled.hashCode());
        result = prime * result + ((twitchBadgesEnabled == null) ? 0 : twitchBadgesEnabled.hashCode());
        result = prime * result + ((ffzBadgesEnabled == null) ? 0 : ffzBadgesEnabled.hashCode());
        result = prime * result + ((ffzEnabled == null) ? 0 : ffzEnabled.hashCode());
        result = prime * result + ((ffzLoadedChannel == null) ? 0 : ffzLoadedChannel.hashCode());
        result = prime * result + ((ffzGlobalLoaded == null) ? 0 : ffzGlobalLoaded.hashCode());
        result = prime * result + ((bttvEnabled == null) ? 0 : bttvEnabled.hashCode());
        result = prime * result + ((bttvLoadedChannel == null) ? 0 : bttvLoadedChannel.hashCode());
        result = prime * result + ((bttvGlobalLoaded == null) ? 0 : bttvGlobalLoaded.hashCode());
        result = prime * result + ((emojiScale == null) ? 0 : emojiScale.hashCode());
        result = prime * result + ((emojiScaleToLine == null) ? 0 : emojiScaleToLine.hashCode());
        result = prime * result + ((badgeScale == null) ? 0 : badgeScale.hashCode());
        result = prime * result + ((badgeScaleToLine == null) ? 0 : badgeScaleToLine.hashCode());
        result = prime * result + ((badgeHeightOffset == null) ? 0 : badgeHeightOffset.hashCode());
        result = prime * result + ((twitchEnabled == null) ? 0 : twitchEnabled.hashCode());
        result = prime * result + ((twitchLoaded == null) ? 0 : twitchLoaded.hashCode());
        result = prime * result + ((twitchBadgesLoadedChannel == null) ? 0 : twitchBadgesLoadedChannel.hashCode());
        return result;
    }

    @Override
    public boolean equals(Object obj)
    {
        if (this == obj)
        {
            return true;
        }
        if (obj == null)
        {
            return false;
        }
        if (getClass() != obj.getClass())
        {
            return false;
        }
        ConfigEmoji other = (ConfigEmoji) obj;
        if (displayStrategy != other.displayStrategy)
        {
            return false;
        }
        if (emojiEnabled == null)
        {
            if (other.emojiEnabled != null)
            {
                return false;
            }
        }
        else if (!emojiEnabled.equals(other.emojiEnabled))
        {
            return false;
        }
        if (twitchBadgesEnabled == null)
        {
            if (other.twitchBadgesEnabled != null)
            {
                return false;
            }
        }
        else if (!twitchBadgesEnabled.equals(other.twitchBadgesEnabled))
        {
            return false;
        }
        if (ffzBadgesEnabled == null)
        {
            if (other.ffzBadgesEnabled != null)
            {
                return false;
            }
        }
        else if (!ffzBadgesEnabled.equals(other.ffzBadgesEnabled))
        {
            return false;
        }
        if (ffzEnabled == null)
        {
            if (other.ffzEnabled != null)
            {
                return false;
            }
        }
        else if (!ffzEnabled.equals(other.ffzEnabled))
        {
            return false;
        }
        if (ffzLoadedChannel == null)
        {
            if (other.ffzLoadedChannel != null)
            {
                return false;
            }
        }
        else if (!ffzLoadedChannel.equals(other.ffzLoadedChannel))
        {
            return false;
        }
        if (ffzGlobalLoaded == null)
        {
            if (other.ffzGlobalLoaded != null)
            {
                return false;
            }
        }
        else if (!ffzGlobalLoaded.equals(other.ffzGlobalLoaded))
        {
            return false;
        }
        if (bttvEnabled == null)
        {
            if (other.bttvEnabled != null)
            {
                return false;
            }
        }
        else if (!bttvEnabled.equals(other.bttvEnabled))
        {
            return false;
        }
        if (bttvLoadedChannel == null)
        {
            if (other.bttvLoadedChannel != null)
            {
                return false;
            }
        }
        else if (!bttvLoadedChannel.equals(other.bttvLoadedChannel))
        {
            return false;
        }
        if (bttvGlobalLoaded == null)
        {
            if (other.bttvGlobalLoaded != null)
            {
                return false;
            }
        }
        else if (!bttvGlobalLoaded.equals(other.bttvGlobalLoaded))
        {
            return false;
        }
        if (emojiScale == null)
        {
            if (other.emojiScale != null)
            {
                return false;
            }
        }
        else if (!emojiScale.equals(other.emojiScale))
        {
            return false;
        }
        if (badgeScale == null)
        {
            if (other.badgeScale != null)
            {
                return false;
            }
        }
        else if (!badgeScale.equals(other.badgeScale))
        {
            return false;
        }
        if (emojiScaleToLine == null)
        {
            if (other.emojiScaleToLine != null)
            {
                return false;
            }
        }
        else if (!emojiScaleToLine.equals(other.emojiScaleToLine))
        {
            return false;
        }
        if (badgeScaleToLine == null)
        {
            if (other.badgeScaleToLine != null)
            {
                return false;
            }
        }
        else if (!badgeScaleToLine.equals(other.badgeScaleToLine))
        {
            return false;
        }
        if (badgeHeightOffset == null)
        {
            if (other.badgeHeightOffset != null)
            {
                return false;
            }
        }
        else if (!badgeHeightOffset.equals(other.badgeHeightOffset))
        {
            return false;
        }
        if (twitchEnabled == null)
        {
            if (other.twitchEnabled != null)
            {
                return false;
            }
        }
        else if (!twitchEnabled.equals(other.twitchEnabled))
        {
            return false;
        }
        if (twitchLoaded == null)
        {
            if (other.twitchLoaded != null)
            {
                return false;
            }
        }
        else if (!twitchLoaded.equals(other.twitchLoaded))
        {
            return false;
        }
        if (twitchBadgesLoadedChannel == null)
        {
            if (other.twitchBadgesLoadedChannel != null)
            {
                return false;
            }
        }
        else if (!twitchBadgesLoadedChannel.equals(other.twitchBadgesLoadedChannel))
        {
            return false;
        }
        if (twitterEnabled == null)
        {
            if (other.twitterEnabled != null)
            {
                return false;
            }
        }
        else if (!twitterEnabled.equals(other.twitterEnabled))
        {
            return false;
        }
        return true;
    }

    /**
     * Perform a deep copy of the emoji config, used to compare against the previous one used to generated the string of
     * characters and emojis that are stored in a Message object
     * 
     * @param copy
     */
    public void deepCopy(ConfigEmoji copy)
    {
        this.emojiEnabled = copy.emojiEnabled;
        this.twitchBadgesEnabled = copy.twitchBadgesEnabled;
        this.ffzBadgesEnabled = copy.ffzBadgesEnabled;
        this.emojiScaleToLine = copy.emojiScaleToLine;
        this.emojiScale = copy.emojiScale;
        this.badgeScaleToLine = copy.badgeScaleToLine;
        this.badgeHeightOffset = copy.badgeHeightOffset;
        this.badgeScale = copy.badgeScale;
        this.displayStrategy = copy.displayStrategy;
        this.twitchEnabled = copy.twitchEnabled;
        this.ffzEnabled = copy.ffzEnabled;
        this.twitchLoaded = copy.twitchLoaded;
        this.twitchBadgesLoadedChannel = copy.twitchBadgesLoadedChannel;
        this.ffzLoadedChannel = copy.ffzLoadedChannel;
        this.ffzGlobalLoaded = copy.ffzGlobalLoaded;
        this.bttvEnabled = copy.bttvEnabled;
        this.bttvCacheEnabled = copy.bttvCacheEnabled;
        this.bttvLoadedChannel = copy.bttvLoadedChannel;
        this.bttvGlobalLoaded = copy.bttvGlobalLoaded;
        this.bttvCached = copy.bttvCached;
        this.twitterEnabled = copy.twitterEnabled;
    }

    /**
     * Get whether the Twitch emotes have been loaded
     * 
     * @return twitchLoaded
     */
    public Boolean isTwitchLoaded()
    {
        return twitchLoaded != null && twitchLoaded;
    }

    /**
     * Set whether the Twitch emotes have been loaded
     * 
     * @param twitchLoaded
     */
    public void setTwitchLoaded(Boolean twitchLoaded)
    {
        this.twitchLoaded = twitchLoaded;
    }

    /**
     * Get whether the global Twitch emotes have been cached
     * 
     * @return twitchCached
     */
    public Boolean isTwitchCached()
    {
        return twitchCached != null && twitchCached;
    }

    /**
     * Set whether the global Twitch emotes have been cached
     * 
     * @param twitchCached
     */
    public void setTwitchCached(Boolean twitchCached)
    {
        this.twitchCached = twitchCached;
    }

    /**
     * Get whether the Twitch badges have been loaded for the specified channel
     * 
     * @return loaded
     */
    public boolean isTwitchBadgesLoaded(String testChannel)
    {
        return twitchBadgesLoadedChannel != null && twitchBadgesLoadedChannel.equals(testChannel);
    }

    /**
     * Get whether the FrankerFaceZ badges have been loaded for the specified channel
     * 
     * @return loaded
     */
    public boolean isFfzBadgesLoaded(String testChannel)
    {
        return ffzBadgesLoadedChannel != null && ffzBadgesLoadedChannel.equals(testChannel);
    }

    /**
     * Set whether the Twitch badges have been loaded
     * 
     * @param twitchLoadedChannel
     *            from which the Twitch emotes are loaded
     */
    public void setTwitchBadgesLoaded(String twitchBadgesLoadedChannel)
    {
        this.twitchBadgesLoadedChannel = twitchBadgesLoadedChannel;
    }

    /**
     * Set whether the FrankerFaceZ badges have been loaded
     * 
     * @param ffzLoadedChannel
     *            from which the FrankerFaceZ emotes are loaded
     */
    public void setFfzBadgesLoaded(String ffzBadgesLoadedChannel)
    {
        this.ffzBadgesLoadedChannel = ffzBadgesLoadedChannel;
    }

    /**
     * Get whether the FrankerFaceZ emotes have been loaded for the specified channel
     * 
     * @return loaded
     */
    public boolean isFfzLoaded(String testChannel)
    {
        return ffzLoadedChannel != null && ffzLoadedChannel.equals(testChannel);
    }

    /**
     * Set whether the FrankerFaceZ emotes have been loaded
     * 
     * @param ffzLoadedChannel
     *            from which the FrankerFaceZ emotes are loaded
     */
    public void setFfzLoaded(String ffzLoadedChannel)
    {
        this.ffzLoadedChannel = ffzLoadedChannel;
    }

    /**
     * Get whether the global FrankerFaceZ emotes have been loaded
     * 
     * @return ffzGlobalLoaded
     */
    public Boolean isFfzGlobalLoaded()
    {
        return ffzGlobalLoaded != null && ffzGlobalLoaded;
    }

    /**
     * Set whether the global FrankerFaceZ emotes have been loaded
     * 
     * @param ffzGlobalLoaded
     */
    public void setFfzGlobalLoaded(Boolean ffzGlobalLoaded)
    {
        this.ffzGlobalLoaded = ffzGlobalLoaded;
    }

    /**
     * Get whether the FrankerFaceZ emotes have been cached
     * 
     * @return ffzCached
     */
    public boolean isFfzCached()
    {
        return ffzCached != null && ffzCached;
    }

    /**
     * Set whether the FrankerFaceZ emotes have been cached
     * 
     * @param ffzCached
     */
    public void setFfzCached(Boolean ffzCached)
    {
        this.ffzCached = ffzCached;
    }

    public void setBttvEnabled(Boolean bttvEnabled)
    {
        this.bttvEnabled = bttvEnabled;
        props.setProperty(FontificatorProperties.KEY_EMOJI_BTTV_ENABLE, Boolean.toString(bttvEnabled));
    }

    public String getBttvLoadedChannel()
    {
        return bttvLoadedChannel;
    }

    public void setBttvLoadedChannel(String bttvLoadedChannel)
    {
        this.bttvLoadedChannel = bttvLoadedChannel;
    }

    public void setBttvGlobalLoaded(Boolean bttvGlobalLoaded)
    {
        this.bttvGlobalLoaded = bttvGlobalLoaded;
    }

    public boolean isBttvLoaded(String testChannel)
    {
        return bttvLoadedChannel != null && bttvLoadedChannel.equals(testChannel);
    }

    public void setBttfLoaded(String bttvLoadedChannel)
    {
        this.bttvLoadedChannel = bttvLoadedChannel;
    }

    public Boolean isBttvGlobalLoaded()
    {
        return bttvGlobalLoaded != null && bttvGlobalLoaded;
    }

    public void setBttfGlobalLoaded(Boolean bttvGlobalLoaded)
    {
        this.bttvGlobalLoaded = bttvGlobalLoaded;
    }

    public boolean isBttvCached()
    {
        return bttvCached != null && bttvCached;
    }

    public void setBttvCached(Boolean bttvCached)
    {
        this.bttvCached = bttvCached;
    }

    public boolean isTwitterEnabled()
    {
        return twitterEnabled;
    }

    public void setTwitterEnabled(Boolean twitterEnabled)
    {
        this.twitterEnabled = twitterEnabled;
        props.setProperty(FontificatorProperties.KEY_EMOJI_TWITTER_ENABLE, Boolean.toString(twitterEnabled));
    }

    /**
     * Mark work as being completed by setting the loaded and cached member variables of this emoji config
     * 
     * @param job
     *            Contains the type, op, and channel of the job completed
     */
    public void setWorkCompleted(EmojiJob job)
    {
        logger.trace(job.toString() + " completed");

        final EmojiType emojiType = job.getType();
        final EmojiOperation emojiOp = job.getOp();

        if (EmojiType.TWITCH_BADGE.equals(emojiType))
        {
            this.twitchBadgesLoadedChannel = job.getChannel();
        }
        else if (EmojiType.FRANKERFACEZ_BADGE.equals(emojiType))
        {
            this.ffzBadgesLoadedChannel = job.getChannel();
        }
        else if (emojiType.isTwitchEmote()) // This would also include TWITCH_BADGE, but it's already checked above
        {
            if (EmojiOperation.LOAD == emojiOp)
            {
                this.twitchLoaded = true;
            }
            else if (EmojiOperation.CACHE == emojiOp)
            {
                this.twitchCached = true;
            }
        }
        else if (emojiType.isFrankerFaceZEmote())
        {
            if (EmojiOperation.LOAD == emojiOp)
            {
                if (emojiType == EmojiType.FRANKERFACEZ_CHANNEL)
                {
                    this.ffzLoadedChannel = job.getChannel();
                }
                else if (emojiType == EmojiType.FRANKERFACEZ_GLOBAL)
                {
                    this.ffzGlobalLoaded = true;
                }
            }
            else if (EmojiOperation.CACHE == emojiOp)
            {
                this.ffzCached = true;
            }
        }
        else if (emojiType.isBetterTtvEmote())
        {
            if (EmojiOperation.LOAD == emojiOp)
            {
                if (emojiType == EmojiType.BETTER_TTV_CHANNEL)
                {
                    this.bttvLoadedChannel = job.getChannel();
                }
                else if (emojiType == EmojiType.BETTER_TTV_GLOBAL)
                {
                    this.bttvGlobalLoaded = true;
                }
            }
            else if (EmojiOperation.CACHE == emojiOp)
            {
                this.bttvCached = true;
            }
        }
    }

    /**
     * Reset the flags indicating any completed work. This enables a reloading of everything. It does not clear out the
     * previously loaded or cached data, just enables the system to reload or recache it.
     */
    public void resetWorkCompleted()
    {
        this.twitchLoaded = false;
        this.twitchCached = false;
        this.twitchBadgesLoadedChannel = null;
        this.ffzBadgesLoadedChannel = null;
        this.ffzLoadedChannel = null;
        this.ffzGlobalLoaded = false;
        this.ffzCached = null;
        this.bttvLoadedChannel = null;
        this.bttvGlobalLoaded = false;
        this.bttvCached = null;
    }

    /**
     * Get whether any work been done to load or cache any emoji or badges
     * 
     * @return whether work was done
     */
    public boolean isAnyWorkDone()
    {
        // @formatter:off
        return twitchBadgesLoadedChannel != null || isTwitchLoaded() || isTwitchCached() || 
               ffzBadgesLoadedChannel != null || ffzLoadedChannel != null || isFfzGlobalLoaded() || isFfzCached() || 
               bttvLoadedChannel != null || isBttvGlobalLoaded() || isBttvCached();
        // @formatter:on
    }

} public enum EmojiLoadingDisplayStragegy
{
    // @formatter:off
    UNKNOWN("Display the specified unknown character"),
    SPACE("Display a space of the expected image size"), 
    NOTHING("Do not display anything"), 
    BOX_FRAME("Display the frame of a box"), 
    BOX_FILL("Display a filled box");
    // @formatter:on

    /**
     * The text to display representing the type in the dropdown menu
     */
    private String label;

    private EmojiLoadingDisplayStragegy(String label)
    {
        this.label = label;
    }

    public String toString()
    {
        return label;
    }

    public static boolean contains(String name)
    {
        for (int i = 0; i < values().length; i++)
        {
            if (values()[i].name().equals(name))
            {
                return true;
            }
        }
        return false;
    }
} public class FontificatorProperties extends Properties
{
    private static final Logger logger = Logger.getLogger(FontificatorProperties.class);

    private static final long serialVersionUID = 1L;

    /**
     * Copy of properties to compare with when checking if there have been changes
     */
    private FontificatorProperties lastSavedCopy;

    private final static String ENC_PASSWORD = "Eastmost penninsula is the secret.";

    /**
     * The name of the file that holds the location of the last file saved or loaded by a user, to be used when the
     * program starts to automatically load the previously used configuration
     */
    private static final String CONFIG_FILE_LAST_LOCATION = ".fontificator.conf";

    public static final String KEY_IRC_USER = "ircUser";
    public static final String KEY_IRC_HOST = "ircHost";
    public static final String KEY_IRC_PORT = "ircPort";
    public static final String KEY_IRC_AUTH = "ircAuth";
    public static final String KEY_IRC_ANON = "ircAnon";
    public static final String KEY_IRC_CHAN = "ircChannel";
    public static final String KEY_IRC_AUTO_RECONNECT = "ircAutoReconnect";

    public static final String[] IRC_KEYS = new String[] { KEY_IRC_USER, KEY_IRC_HOST, KEY_IRC_PORT, KEY_IRC_AUTH, KEY_IRC_ANON, KEY_IRC_CHAN, KEY_IRC_AUTO_RECONNECT };

    public static final String KEY_FONT_FILE_BORDER = "fontBorderFile";
    public static final String KEY_FONT_FILE_FONT = "fontFile";
    public static final String KEY_FONT_TYPE = "fontType";
    public static final String KEY_FONT_GRID_WIDTH = "fontGridWidth";
    public static final String KEY_FONT_GRID_HEIGHT = "fontGridHeight";
    public static final String KEY_FONT_SCALE = "fontScale";
    public static final String KEY_FONT_BORDER_SCALE = "fontBorderScale";
    public static final String KEY_FONT_BORDER_INSET_X = "fontBorderInsetX";
    public static final String KEY_FONT_BORDER_INSET_Y = "fontBorderInsetY";
    public static final String KEY_FONT_SPACE_WIDTH = "fontSpaceWidth";
    public static final String KEY_FONT_BASELINE_OFFSET = "fontBaselineOffset";
    public static final String KEY_FONT_UNKNOWN_CHAR = "fontUnknownChar";
    public static final String KEY_FONT_EXTENDED_CHAR = "fontExtendedChar";
    public static final String KEY_FONT_CHARACTERS = "fontCharacters";
    public static final String KEY_FONT_SPACING_LINE = "fontLineSpacing";
    public static final String KEY_FONT_SPACING_CHAR = "fontCharSpacing";
    public static final String KEY_FONT_SPACING_MESSAGE = "fontMessageSpacing";

    public static final String[] FONT_KEYS = new String[] { KEY_FONT_FILE_BORDER, KEY_FONT_FILE_FONT, KEY_FONT_TYPE, KEY_FONT_GRID_WIDTH, KEY_FONT_GRID_HEIGHT, KEY_FONT_SCALE, KEY_FONT_BORDER_SCALE, KEY_FONT_BORDER_INSET_X, KEY_FONT_BORDER_INSET_Y, KEY_FONT_SPACE_WIDTH, KEY_FONT_BASELINE_OFFSET, KEY_FONT_UNKNOWN_CHAR, KEY_FONT_EXTENDED_CHAR, KEY_FONT_CHARACTERS, KEY_FONT_SPACING_LINE, KEY_FONT_SPACING_CHAR, KEY_FONT_SPACING_MESSAGE };

    public static final String KEY_CHAT_SCROLL = "chatScrollEnabled";
    public static final String KEY_CHAT_RESIZABLE = "chatResizable";
    public static final String KEY_CHAT_POSITION = "chatPosition";
    public static final String KEY_CHAT_POSITION_X = "chatPositionX";
    public static final String KEY_CHAT_POSITION_Y = "chatPositionY";
    public static final String KEY_CHAT_FROM_BOTTOM = "chatFromBottom";
    public static final String KEY_CHAT_WINDOW_WIDTH = "chatWidth"; // Legacy, no longer saved, used for chat window, not chat window content pane
    public static final String KEY_CHAT_WINDOW_HEIGHT = "chatHeight"; // Legacy, no longer saved, used for chat window, not chat window content pane
    public static final String KEY_CHAT_WIDTH = "chatPixelWidth";
    public static final String KEY_CHAT_HEIGHT = "chatPixelHeight";
    public static final String KEY_CHAT_CHROMA_ENABLED = "chromaEnabled";
    public static final String KEY_CHAT_INVERT_CHROMA = "invertChroma";
    public static final String KEY_CHAT_REVERSE_SCROLLING = "reverseScrolling";
    public static final String KEY_CHAT_CHROMA_LEFT = "chromaLeft";
    public static final String KEY_CHAT_CHROMA_TOP = "chromaTop";
    public static final String KEY_CHAT_CHROMA_RIGHT = "chromaRight";
    public static final String KEY_CHAT_CHROMA_BOTTOM = "chromaBottom";
    public static final String KEY_CHAT_CHROMA_CORNER = "chromaCornerRadius";
    public static final String KEY_CHAT_ALWAYS_ON_TOP = "chatAlwaysOnTop";
    public static final String KEY_CHAT_ANTIALIAS = "chatAntialias";

    public static final String[] CHAT_KEYS = new String[] { KEY_CHAT_SCROLL, KEY_CHAT_RESIZABLE, KEY_CHAT_POSITION, KEY_CHAT_POSITION_X, KEY_CHAT_POSITION_Y, KEY_CHAT_FROM_BOTTOM, KEY_CHAT_WIDTH, KEY_CHAT_HEIGHT, KEY_CHAT_CHROMA_ENABLED, KEY_CHAT_INVERT_CHROMA, KEY_CHAT_REVERSE_SCROLLING, KEY_CHAT_CHROMA_LEFT, KEY_CHAT_CHROMA_TOP, KEY_CHAT_CHROMA_RIGHT, KEY_CHAT_CHROMA_BOTTOM, KEY_CHAT_CHROMA_CORNER, KEY_CHAT_ALWAYS_ON_TOP, KEY_CHAT_ANTIALIAS };

    public static final String[] CHAT_KEYS_EXCEPT_WINDOW_POSITION = new String[] { KEY_CHAT_SCROLL, KEY_CHAT_RESIZABLE, KEY_CHAT_POSITION, KEY_CHAT_FROM_BOTTOM, KEY_CHAT_WIDTH, KEY_CHAT_HEIGHT, KEY_CHAT_CHROMA_ENABLED, KEY_CHAT_INVERT_CHROMA, KEY_CHAT_REVERSE_SCROLLING, KEY_CHAT_CHROMA_LEFT, KEY_CHAT_CHROMA_TOP, KEY_CHAT_CHROMA_RIGHT, KEY_CHAT_CHROMA_BOTTOM, KEY_CHAT_CHROMA_CORNER, KEY_CHAT_ALWAYS_ON_TOP, KEY_CHAT_ANTIALIAS };

    public static final String KEY_COLOR_BG = "colorBackground";
    public static final String KEY_COLOR_FG = "colorForeground";
    public static final String KEY_COLOR_BORDER = "colorBorder";
    public static final String KEY_COLOR_HIGHLIGHT = "colorHighlight";
    public static final String KEY_COLOR_CHROMA_KEY = "chromaKey";
    public static final String KEY_COLOR_PALETTE = "colorPalette";
    public static final String KEY_COLOR_USERNAME = "colorUsername";
    public static final String KEY_COLOR_TIMESTAMP = "colorTimestamp";
    public static final String KEY_COLOR_MESSAGE = "colorMessage";
    public static final String KEY_COLOR_JOIN = "colorJoin";
    public static final String KEY_COLOR_TWITCH = "colorUseTwitch";

    public static final String[] COLOR_KEYS = new String[] { KEY_COLOR_BG, KEY_COLOR_FG, KEY_COLOR_BORDER, KEY_COLOR_HIGHLIGHT, KEY_COLOR_CHROMA_KEY, KEY_COLOR_PALETTE, KEY_COLOR_USERNAME, KEY_COLOR_TIMESTAMP, KEY_COLOR_MESSAGE, KEY_COLOR_JOIN, KEY_COLOR_TWITCH };

    public static final String[] COLOR_KEYS_WITHOUT_PALETTE = new String[] { KEY_COLOR_BG, KEY_COLOR_FG, KEY_COLOR_BORDER, KEY_COLOR_HIGHLIGHT, KEY_COLOR_CHROMA_KEY, KEY_COLOR_USERNAME, KEY_COLOR_TIMESTAMP, KEY_COLOR_MESSAGE, KEY_COLOR_JOIN, KEY_COLOR_TWITCH };

    public static final String KEY_MESSAGE_JOIN = "messageShowJoin";
    public static final String KEY_MESSAGE_USERNAME = "messageShowUsername";
    public static final String KEY_MESSAGE_TIMESTAMP = "messageShowTimestamp";
    public static final String KEY_MESSAGE_USERFORMAT = "messageUsernameFormat";
    public static final String KEY_MESSAGE_TIMEFORMAT = "messageTimestampFormat";
    public static final String KEY_MESSAGE_CONTENT_BREAK = "messageContentBreak";
    public static final String KEY_MESSAGE_QUEUE_SIZE = "messageQueueSize";
    public static final String KEY_MESSAGE_SPEED = "messageSpeed";
    public static final String KEY_MESSAGE_EXPIRATION_TIME = "messageExpirationTime";
    public static final String KEY_MESSAGE_HIDE_EMPTY_BORDER = "messageHideEmptyBorder";
    public static final String KEY_MESSAGE_HIDE_EMPTY_BACKGROUND = "messageHideEmptyBackground";
    public static final String KEY_MESSAGE_CASE_TYPE = "messageUserCase";
    public static final String KEY_MESSAGE_CASE_SPECIFY = "messageUserCaseSpecify";
    public static final String KEY_MESSAGE_CASING = "messageCasing";

    public static final String[] MESSAGE_KEYS = new String[] { KEY_MESSAGE_JOIN, KEY_MESSAGE_USERNAME, KEY_MESSAGE_TIMESTAMP, KEY_MESSAGE_USERFORMAT, KEY_MESSAGE_TIMEFORMAT, KEY_MESSAGE_CONTENT_BREAK, KEY_MESSAGE_QUEUE_SIZE, KEY_MESSAGE_SPEED, KEY_MESSAGE_EXPIRATION_TIME, KEY_MESSAGE_HIDE_EMPTY_BORDER, KEY_MESSAGE_HIDE_EMPTY_BACKGROUND, KEY_MESSAGE_CASE_TYPE, KEY_MESSAGE_CASE_SPECIFY, KEY_MESSAGE_CASING };

    public static final String KEY_EMOJI_ENABLED = "emojiEnabled";
    public static final String KEY_EMOJI_ANIMATION = "emojiAnimationEnabled";
    public static final String KEY_EMOJI_TWITCH_BADGES = "badgesEnabled";
    public static final String KEY_EMOJI_FFZ_BADGES = "badgesFfzEnabled";
    public static final String KEY_EMOJI_SCALE_TO_LINE = "emojiScaleToLine";
    public static final String KEY_EMOJI_BADGE_SCALE_TO_LINE = "badgeScaleToLine";
    public static final String KEY_EMOJI_BADGE_HEIGHT_OFFSET = "badgeHeightOffset";
    public static final String KEY_EMOJI_SCALE = "emojiScale";
    public static final String KEY_EMOJI_BADGE_SCALE = "badgeScale";
    public static final String KEY_EMOJI_DISPLAY_STRAT = "emojiDisplayStrat";
    public static final String KEY_EMOJI_TWITCH_ENABLE = "emojiTwitchEnabled";
    public static final String KEY_EMOJI_TWITCH_CACHE = "emojiTwitchCached";
    public static final String KEY_EMOJI_FFZ_ENABLE = "emojiFfzEnabled";
    public static final String KEY_EMOJI_FFZ_CACHE = "emojiFfzCached";
    public static final String KEY_EMOJI_BTTV_ENABLE = "emojiBttvEnabled";
    public static final String KEY_EMOJI_BTTV_CACHE = "emojiBttvCached";
    public static final String KEY_EMOJI_TWITTER_ENABLE = "emojiTwitterEnabled";

    public static final String[] EMOJI_KEYS = new String[] { KEY_EMOJI_ENABLED, KEY_EMOJI_ANIMATION, KEY_EMOJI_TWITCH_BADGES, KEY_EMOJI_FFZ_BADGES, KEY_EMOJI_SCALE_TO_LINE, KEY_EMOJI_BADGE_SCALE_TO_LINE, KEY_EMOJI_BADGE_HEIGHT_OFFSET, KEY_EMOJI_SCALE, KEY_EMOJI_BADGE_SCALE, KEY_EMOJI_DISPLAY_STRAT, KEY_EMOJI_TWITCH_ENABLE, KEY_EMOJI_TWITCH_CACHE, KEY_EMOJI_FFZ_ENABLE, KEY_EMOJI_FFZ_CACHE, KEY_EMOJI_BTTV_ENABLE, KEY_EMOJI_BTTV_CACHE, KEY_EMOJI_TWITTER_ENABLE };

    public static final String KEY_CENSOR_ENABLED = "censorEnabled";
    public static final String KEY_CENSOR_PURGE_ON_TWITCH_BAN = "censorPurgeOnTwitchBan";
    public static final String KEY_CENSOR_URL = "censorUrl";
    public static final String KEY_CENSOR_FIRST_URL = "censorFirstUrl";
    public static final String KEY_CENSOR_UNKNOWN_CHARS = "censorUnknownChars";
    public static final String KEY_CENSOR_UNKNOWN_CHARS_PERCENT = "censorUnknownCharsPercent";
    public static final String KEY_CENSOR_WHITE = "censorWhitelist";
    public static final String KEY_CENSOR_BLACK = "censorBlacklist";
    public static final String KEY_CENSOR_BANNED = "censorBannedWords";

    public static final String[] CENSOR_KEYS = new String[] { KEY_CENSOR_ENABLED, KEY_CENSOR_PURGE_ON_TWITCH_BAN, KEY_CENSOR_URL, KEY_CENSOR_FIRST_URL, KEY_CENSOR_UNKNOWN_CHARS, KEY_CENSOR_UNKNOWN_CHARS_PERCENT, KEY_CENSOR_WHITE, KEY_CENSOR_BLACK, KEY_CENSOR_BANNED };

    public static final String[][] ALL_KEYS_EXCEPT_CHAT_WINDOW_POSITION = new String[][] { IRC_KEYS, FONT_KEYS, CHAT_KEYS_EXCEPT_WINDOW_POSITION, COLOR_KEYS, MESSAGE_KEYS, EMOJI_KEYS, CENSOR_KEYS };

    public static final String[][] ALL_KEYS = new String[][] { IRC_KEYS, FONT_KEYS, CHAT_KEYS, COLOR_KEYS, MESSAGE_KEYS, EMOJI_KEYS, CENSOR_KEYS };

    /**
     * Get all the property keys to be tested to determine if two fProps are equal. The parameter FontificatorProperties
     * is to be used as a collection of configuration that is used to select which properties matter. For example, if
     * chat window position should be remembered or not, so if remember position is unchecked, it doesn't ask you to
     * save changes just because you moved the window.
     * 
     * @param fProps
     *            contains configuration to be used to determine which keys are needed
     * @return all keys to be tested for equals
     */
    private static String[][] getKeysForEqualsTest(FontificatorProperties fProps)
    {
        if (fProps != null && fProps.getChatConfig() != null && fProps.getChatConfig().isRememberPosition())
        {
            return ALL_KEYS_EXCEPT_CHAT_WINDOW_POSITION;
        }

        return ALL_KEYS;
    }

    private ConfigIrc ircConfig = new ConfigIrc();

    private ConfigFont fontConfig = new ConfigFont();

    private ConfigChat chatConfig = new ConfigChat();

    private ConfigColor colorConfig = new ConfigColor();

    private ConfigMessage messageConfig = new ConfigMessage();

    private ConfigEmoji emojiConfig = new ConfigEmoji();

    private ConfigCensor censorConfig = new ConfigCensor();

    public FontificatorProperties()
    {
    }

    @Override
    public void clear()
    {
        ircConfig.reset();
        fontConfig.reset();
        chatConfig.reset();
        colorConfig.reset();
        messageConfig.reset();
        emojiConfig.reset();
        censorConfig.reset();
        super.clear();
    }

    public ConfigIrc getIrcConfig()
    {
        return ircConfig;
    }

    public ConfigFont getFontConfig()
    {
        return fontConfig;
    }

    public ConfigChat getChatConfig()
    {
        return chatConfig;
    }

    public ConfigColor getColorConfig()
    {
        return colorConfig;
    }

    public ConfigMessage getMessageConfig()
    {
        return messageConfig;
    }

    public ConfigEmoji getEmojiConfig()
    {
        return emojiConfig;
    }

    public ConfigCensor getCensorConfig()
    {
        return censorConfig;
    }

    /**
     * Called whenever unsaved changes might be lost to let the user have the option to save them
     * 
     * @param ctrlWindow
     * @return okayToContinue
     */
    public boolean checkForUnsavedProps(ControlWindow ctrlWindow, Component parent)
    {
        if (hasUnsavedChanges())
        {
            int response = JOptionPane.showConfirmDialog(parent, "Save configuration changes?", "Unsaved Changes", JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE);
            if (response == JOptionPane.YES_OPTION)
            {
                if (ctrlWindow.saveConfig())
                {
                    return true;
                }
            }
            else if (response == JOptionPane.NO_OPTION)
            {
                return true;
            }

            return false;
        }
        else
        {
            return true;
        }
    }

    /**
     * Load configuration from the file indicated by the specified filename, or a preset file contained in the classpath
     * resource directory
     * 
     * @param filename
     * @return report
     * @throws Exception
     */
    public LoadConfigReport loadFile(String filename) throws Exception
    {
        if (filename.startsWith(ConfigFont.INTERNAL_FILE_PREFIX))
        {
            final String plainFilename = filename.substring(ConfigFont.INTERNAL_FILE_PREFIX.length());

            if (getClass().getClassLoader().getResource(plainFilename) == null)
            {
                LoadConfigReport report = new LoadConfigReport();
                final String errorMsg = "Preset theme " + plainFilename + " not found";
                ChatWindow.popup.handleProblem(errorMsg);
                report.addError(errorMsg, LoadConfigErrorType.FILE_NOT_FOUND);
                return report;
            }

            InputStream is = getClass().getClassLoader().getResourceAsStream(plainFilename);
            LoadConfigReport report = loadFile(is, filename, true);

            if (report.isErrorFree())
            {
                // This is a temporary copy, not to be saved to the last saved file for loading next time, but to avoid
                // having to not save when switching between presets
                this.lastSavedCopy = getCopy();
            }

            is.close();
            return report;
        }
        else
        {
            return loadFile(new File(filename));
        }
    }

    /**
     * Load configuration from the specified file
     * 
     * @param file
     * @return report
     * @throws Exception
     */
    public LoadConfigReport loadFile(File file) throws Exception
    {
        logger.trace("Loading file " + file.getAbsolutePath());
        InputStream is = new FileInputStream(file);
        LoadConfigReport report = loadFile(is, file.getAbsolutePath(), false);
        is.close();
        return report;
    }

    /**
     * Load file from InputStream. Does not close InputStream
     * 
     * @param is
     * @param filename
     * @param isPreset
     * @return report
     * @throws Exception
     */
    private LoadConfigReport loadFile(InputStream is, String filename, boolean isPreset) throws Exception
    {
        final String prevAuth = getProperty(KEY_IRC_AUTH);
        super.load(is);
        final String currAuth = getProperty(KEY_IRC_AUTH);

        // Only decrypt the auth token here if there wasn't a previously loaded one that was already decrypted. This is
        // determined by whether the previous authorization wasn't loaded (null) or if there has been a change. If the
        // inputstream data didn't have an auth token, then the previous and current ones will match, meaning a new one
        // wasn't loaded and there's no need to decrypt.
        if (prevAuth == null || !prevAuth.equals(currAuth))
        {
            decryptProperty(KEY_IRC_AUTH);
        }

        LoadConfigReport report = loadConfigs(!isPreset);

        if (report.isErrorFree() && !isPreset)
        {
            rememberLastConfigFile(filename);
        }

        return report;
    }

    /**
     * Store the specified path to a configuration file in the last config file location conf file
     * 
     * @param path
     *            The path of the configuration file to remember
     */
    public void rememberLastConfigFile(String path)
    {
        logger.trace("Remembering last loaded file " + path);

        // Keep track of the last saved or loaded copy to compare with to see if a save prompt is required when exiting
        this.lastSavedCopy = getCopy();

        BufferedWriter writer = null;
        try
        {
            writer = new BufferedWriter(new FileWriter(CONFIG_FILE_LAST_LOCATION, false));
            writer.write(path);
        }
        catch (Exception e)
        {
            // Don't alert the user, just log behind the scenes, not important enough to warrant a popup
            logger.error("Unable to save last file loaded", e);
        }
        finally
        {
            if (writer != null)
            {
                try
                {
                    writer.close();
                }
                catch (Exception e)
                {
                    logger.error(e.toString(), e);
                }
            }
        }
    }

    /**
     * Delete the last config file location conf file that stored the path to a configuration file. To be used to remove
     * a file that is not found or has errors.
     */
    public void forgetLastConfigFile()
    {
        logger.trace("Forgetting last loaded");

        File f = new File(CONFIG_FILE_LAST_LOCATION);
        f.delete();
    }

    /**
     * Save the configuration to the specified file
     * 
     * @param file
     * @throws Exception
     */
    public void saveFile(File file) throws Exception
    {
        OutputStream os = new FileOutputStream(file);
        encryptProperty(KEY_IRC_AUTH);
        super.store(os, null);
        decryptProperty(KEY_IRC_AUTH);
        rememberLastConfigFile(file.getAbsolutePath());
    }

    public void encryptProperty(String key)
    {
        BasicTextEncryptor textEncryptor = new BasicTextEncryptor();
        textEncryptor.setPassword(ENC_PASSWORD);

        final String decryptedValue = getProperty(key);
        if (decryptedValue != null && !decryptedValue.isEmpty())
        {
            try
            {
                final String encryptedValue = textEncryptor.encrypt(decryptedValue);
                setProperty(key, encryptedValue);
            }
            catch (Exception e)
            {
                final String errorMessage = "Error encrypting value for " + key + " property";
                logger.error(errorMessage, e);
                ChatWindow.popup.handleProblem(errorMessage);
                setProperty(key, "");
            }
        }
    }

    public void decryptProperty(String key)
    {
        BasicTextEncryptor textEncryptor = new BasicTextEncryptor();
        textEncryptor.setPassword(ENC_PASSWORD);
        final String encryptedValue = getProperty(key);
        if (encryptedValue != null && !encryptedValue.isEmpty())
        {
            try
            {
                final String decryptedValue = textEncryptor.decrypt(encryptedValue);
                setProperty(key, decryptedValue);
            }
            catch (Exception e)
            {
                final String errorMessage = "Error decrypting value for " + key + " property";
                logger.error(errorMessage, e);
                ChatWindow.popup.handleProblem(errorMessage);
                setProperty(key, "");
            }
        }
    }

    /**
     * Try to load the configuration file stored i the last config file location conf file.
     * 
     * @return report
     * @throws Exception
     */
    public LoadConfigReport loadLast() throws Exception
    {
        logger.trace("Load last");

        final String previousConfigNotFound = "Previous configuration not found.";
        final String previousConfigError = "Error loading previous configuration.";

        BufferedReader reader = null;
        try
        {
            File lastFile = new File(CONFIG_FILE_LAST_LOCATION);
            if (!lastFile.exists())
            {
                LoadConfigReport errorReport = new LoadConfigReport();
                errorReport.addError(previousConfigNotFound, LoadConfigErrorType.FILE_NOT_FOUND);
                return errorReport;
            }
            reader = new BufferedReader(new FileReader(lastFile));
            final String lastConfigFilename = reader.readLine();
            reader.close();
            LoadConfigReport report = loadFile(lastConfigFilename);
            if (report.isProblem())
            {
                report.setMainMessage(previousConfigError);
            }
            return report;
        }
        finally
        {
            if (reader != null)
            {
                try
                {
                    reader.close();
                }
                catch (Exception e)
                {
                    logger.error(e.toString(), e);
                }
            }
        }
    }

    /**
     * Set a value only if it isn't already set, optionally overriding if specified
     * 
     * @param key
     * @param value
     * @param override
     */
    private void setPropertyOverride(final String key, final String value, final boolean override)
    {
        final boolean valueExists = getProperty(key) != null && !getProperty(key).isEmpty();
        if (override || !valueExists)
        {
            setProperty(key, value);
        }
    }

    /**
     * Load a default configuration, for if something goes wrong, or if no previously used configuration file is stored
     * 
     * @param override
     *            Whether to override values should they already exist
     */
    public void loadDefaultValues(boolean override)
    {
        logger.trace("Loading default values");

        final String trueString = Boolean.toString(true);
        final String falseString = Boolean.toString(false);

        setPropertyOverride(KEY_IRC_HOST, "irc.twitch.tv", override);
        setPropertyOverride(KEY_IRC_PORT, Integer.toString(6667), override);
        setPropertyOverride(KEY_IRC_ANON, trueString, override);
        setPropertyOverride(KEY_IRC_AUTO_RECONNECT, trueString, override);

        setPropertyOverride(KEY_FONT_FILE_BORDER, ConfigFont.INTERNAL_FILE_PREFIX + "borders/dw3_border.png", override);
        setPropertyOverride(KEY_FONT_FILE_FONT, ConfigFont.INTERNAL_FILE_PREFIX + "fonts/dw3_font.png", override);
        setPropertyOverride(KEY_FONT_TYPE, FontType.FIXED_WIDTH.name(), override);
        setPropertyOverride(KEY_FONT_GRID_WIDTH, Integer.toString(8), override);
        setPropertyOverride(KEY_FONT_GRID_HEIGHT, Integer.toString(12), override);
        setPropertyOverride(KEY_FONT_SCALE, Integer.toString(2), override);
        setPropertyOverride(KEY_FONT_BORDER_SCALE, Integer.toString(3), override);
        setPropertyOverride(KEY_FONT_BORDER_INSET_X, Integer.toString(1), override);
        setPropertyOverride(KEY_FONT_BORDER_INSET_Y, Integer.toString(1), override);
        setPropertyOverride(KEY_FONT_SPACE_WIDTH, Integer.toString(25), override);
        setPropertyOverride(KEY_FONT_BASELINE_OFFSET, Integer.toString(0), override);
        setPropertyOverride(KEY_FONT_CHARACTERS, SpriteFont.NORMAL_ASCII_KEY, override);
        setPropertyOverride(KEY_FONT_UNKNOWN_CHAR, Character.toString((char) 127), override);
        setPropertyOverride(KEY_FONT_EXTENDED_CHAR, trueString, override);
        setPropertyOverride(KEY_FONT_SPACING_LINE, Integer.toString(2), override);
        setPropertyOverride(KEY_FONT_SPACING_CHAR, Integer.toString(0), override);
        setPropertyOverride(KEY_FONT_SPACING_MESSAGE, Integer.toString(0), override);

        setPropertyOverride(KEY_CHAT_SCROLL, falseString, override);
        setPropertyOverride(KEY_CHAT_RESIZABLE, trueString, override);
        setPropertyOverride(KEY_CHAT_POSITION, falseString, override);
        setPropertyOverride(KEY_CHAT_POSITION_X, Integer.toString(0), override);
        setPropertyOverride(KEY_CHAT_POSITION_Y, Integer.toString(0), override);
        setPropertyOverride(KEY_CHAT_FROM_BOTTOM, falseString, override);
        setPropertyOverride(KEY_CHAT_WIDTH, Integer.toString(550), override);
        setPropertyOverride(KEY_CHAT_HEIGHT, Integer.toString(450), override);
        setPropertyOverride(KEY_CHAT_CHROMA_ENABLED, falseString, override);
        setPropertyOverride(KEY_CHAT_INVERT_CHROMA, falseString, override);
        setPropertyOverride(KEY_CHAT_REVERSE_SCROLLING, falseString, override);
        setPropertyOverride(KEY_CHAT_CHROMA_LEFT, Integer.toString(10), override);
        setPropertyOverride(KEY_CHAT_CHROMA_TOP, Integer.toString(10), override);
        setPropertyOverride(KEY_CHAT_CHROMA_RIGHT, Integer.toString(10), override);
        setPropertyOverride(KEY_CHAT_CHROMA_BOTTOM, Integer.toString(10), override);
        setPropertyOverride(KEY_CHAT_CHROMA_CORNER, Integer.toString(10), override);
        setPropertyOverride(KEY_CHAT_ALWAYS_ON_TOP, falseString, override);
        setPropertyOverride(KEY_CHAT_ANTIALIAS, falseString, override);

        setPropertyOverride(KEY_COLOR_BG, "000000", override);
        setPropertyOverride(KEY_COLOR_FG, "FFFFFF", override);
        setPropertyOverride(KEY_COLOR_BORDER, "FFFFFF", override);
        setPropertyOverride(KEY_COLOR_HIGHLIGHT, "6699FF", override);
        setPropertyOverride(KEY_COLOR_PALETTE, "F7977A,FDC68A,FFF79A,A2D39C,6ECFF6,A187BE,F6989D", override);
        setPropertyOverride(KEY_COLOR_CHROMA_KEY, "00FF00", override);
        setPropertyOverride(KEY_COLOR_USERNAME, trueString, override);
        setPropertyOverride(KEY_COLOR_TIMESTAMP, falseString, override);
        setPropertyOverride(KEY_COLOR_MESSAGE, falseString, override);
        setPropertyOverride(KEY_COLOR_JOIN, falseString, override);
        setPropertyOverride(KEY_COLOR_TWITCH, falseString, override);

        setPropertyOverride(KEY_MESSAGE_JOIN, falseString, override);
        setPropertyOverride(KEY_MESSAGE_USERNAME, trueString, override);
        setPropertyOverride(KEY_MESSAGE_TIMESTAMP, falseString, override);
        setPropertyOverride(KEY_MESSAGE_USERFORMAT, ConfigMessage.USERNAME_REPLACE, override);
        setPropertyOverride(KEY_MESSAGE_TIMEFORMAT, "[HH:mm:ss]", override);
        setPropertyOverride(KEY_MESSAGE_CONTENT_BREAK, ConfigMessage.DEFAULT_CONTENT_BREAKER, override);
        setPropertyOverride(KEY_MESSAGE_QUEUE_SIZE, Integer.toString(64), override);
        setPropertyOverride(KEY_MESSAGE_SPEED, Integer.toString((int) (ConfigMessage.MAX_MESSAGE_SPEED * 0.25f)), override);
        setPropertyOverride(KEY_MESSAGE_EXPIRATION_TIME, Integer.toString(0), override);
        setPropertyOverride(KEY_MESSAGE_HIDE_EMPTY_BORDER, falseString, override);
        setPropertyOverride(KEY_MESSAGE_HIDE_EMPTY_BACKGROUND, falseString, override);
        setPropertyOverride(KEY_MESSAGE_CASE_TYPE, UsernameCaseResolutionType.NONE.name(), override);
        setPropertyOverride(KEY_MESSAGE_CASE_SPECIFY, falseString, override);
        setPropertyOverride(KEY_MESSAGE_CASING, MessageCasing.MIXED_CASE.name(), override);

        setPropertyOverride(KEY_EMOJI_ENABLED, trueString, override);
        setPropertyOverride(KEY_EMOJI_ANIMATION, falseString, override);
        setPropertyOverride(KEY_EMOJI_TWITCH_BADGES, trueString, override);
        setPropertyOverride(KEY_EMOJI_FFZ_BADGES, falseString, override);
        setPropertyOverride(KEY_EMOJI_SCALE_TO_LINE, trueString, override);
        setPropertyOverride(KEY_EMOJI_BADGE_SCALE_TO_LINE, falseString, override);
        setPropertyOverride(KEY_EMOJI_BADGE_HEIGHT_OFFSET, Integer.toString(0), override);
        setPropertyOverride(KEY_EMOJI_SCALE, Integer.toString(100), override);
        setPropertyOverride(KEY_EMOJI_BADGE_SCALE, Integer.toString(100), override);
        setPropertyOverride(KEY_EMOJI_DISPLAY_STRAT, EmojiLoadingDisplayStragegy.SPACE.name(), override);
        setPropertyOverride(KEY_EMOJI_TWITCH_ENABLE, trueString, override);
        setPropertyOverride(KEY_EMOJI_TWITCH_CACHE, falseString, override);
        setPropertyOverride(KEY_EMOJI_FFZ_ENABLE, falseString, override);
        setPropertyOverride(KEY_EMOJI_FFZ_CACHE, falseString, override);
        setPropertyOverride(KEY_EMOJI_BTTV_ENABLE, falseString, override);
        setPropertyOverride(KEY_EMOJI_BTTV_CACHE, falseString, override);
        setPropertyOverride(KEY_EMOJI_TWITTER_ENABLE, trueString, override);

        setPropertyOverride(KEY_CENSOR_ENABLED, trueString, override);
        setPropertyOverride(KEY_CENSOR_PURGE_ON_TWITCH_BAN, trueString, override);
        setPropertyOverride(KEY_CENSOR_URL, falseString, override);
        setPropertyOverride(KEY_CENSOR_FIRST_URL, falseString, override);
        setPropertyOverride(KEY_CENSOR_UNKNOWN_CHARS, falseString, override);
        setPropertyOverride(KEY_CENSOR_UNKNOWN_CHARS_PERCENT, Integer.toString(20), override);
        setPropertyOverride(KEY_CENSOR_WHITE, "", override);
        setPropertyOverride(KEY_CENSOR_BLACK, "", override);
        setPropertyOverride(KEY_CENSOR_BANNED, "", override);

        loadConfigs(true);
    }

    private FontificatorProperties getCopy()
    {
        FontificatorProperties copy = new FontificatorProperties();
        for (String key : stringPropertyNames())
        {
            copy.setProperty(key, getProperty(key));
        }
        return copy;
    }

    /**
     * Load properties into specialized config objects. This method returns its own report.
     * 
     * @return success
     */
    private LoadConfigReport loadConfigs(boolean loadNonFontConfig)
    {
        LoadConfigReport report = new LoadConfigReport();
        if (loadNonFontConfig)
        {
            ircConfig.load(this, report);
            chatConfig.load(this, report);
            messageConfig.load(this, report);
            emojiConfig.load(this, report);
            censorConfig.load(this, report);
        }
        fontConfig.load(this, report);
        colorConfig.load(this, report);

        if (!report.isErrorFree())
        {
            if (report.isOnlyMissingKeys())
            {
                report.setMainMessage("<center>Please resave the configuration file<br />to fix these issues:</center>");
            }
            ChatWindow.popup.handleProblem(report);
        }

        return report;
    }

    private boolean hasUnsavedChanges()
    {
        return !equals(lastSavedCopy);
    }

    @Override
    public int hashCode()
    {
        final int prime = 31;
        int result = super.hashCode();
        result = prime * result + ((chatConfig == null) ? 0 : chatConfig.hashCode());
        return result;
    }

    @Override
    public boolean equals(Object obj)
    {
        if (this == obj)
            return true;
        if (!super.equals(obj))
            return false;
        if (getClass() != obj.getClass())
            return false;

        FontificatorProperties otherFp = (FontificatorProperties) obj;

        return FontificatorProperties.propertyBatchMatch(getKeysForEqualsTest(this), this, otherFp);
    }

    private static boolean propertyBatchMatch(String[][] keys, FontificatorProperties a, FontificatorProperties b)
    {
        for (int i = 0; i < keys.length; i++)
        {
            for (int j = 0; j < keys[i].length; j++)
            {
                if (!propertyEquals(a.getProperty(keys[i][j]), b.getProperty(keys[i][j])))
                {
                    return false;
                }
            }
        }
        return true;
    }

    private static boolean propertyEquals(String one, String two)
    {
        if (one == null)
        {
            return two == null;
        }
        else
        {
            return one.equals(two);
        }
    }

} public class LoadConfigReport
{
    private static final int MAX_NUMBER_OF_NON_PROBLEMS = 15;

    private String mainMessage;

    /**
     * A set of types of errors that have been collected in this report
     */
    private Set<LoadConfigErrorType> types;

    /**
     * Human-readable messages describing the errors collected so far
     */
    private List<String> messages;

    /**
     * Instantiates an empty report
     */
    public LoadConfigReport()
    {
        types = new HashSet<LoadConfigErrorType>();
        messages = new ArrayList<String>();
    }

    /**
     * Instantiate this report with only a type, when there is no need for human-readable explanations
     * 
     * @param type
     */
    public LoadConfigReport(LoadConfigErrorType type)
    {
        this();
        types.add(type);
    }

    /**
     * Add an error to this report
     * 
     * @param message
     * @param type
     */
    public void addError(String message, LoadConfigErrorType type)
    {
        messages.add(message);
        types.add(type);
    }

    /**
     * Whether no errors have been reported, whether they are problems or not. This check identifies the load as being capable of handling any subsequent work, no need to supplement the results with defaults.
     * 
     * @return error free
     */
    public boolean isErrorFree()
    {
        return messages.isEmpty();
    }

    /**
     * Get the opposite of whether there is a problem
     * 
     * @return success
     */
    public boolean isSuccess()
    {
        return !isProblem();
    }

    /**
     * Get whether there is a problem. A problem is whenever at least one of the types of errors is marked as a problem-type error.
     * 
     * @return problem
     */
    public boolean isProblem()
    {
        for (LoadConfigErrorType result : types)
        {
            if (result.isProblem())
            {
                return true;
            }
        }
        // No real problems exist, but if there are more than the max number allowed, consider it a problem. For
        // example, someone might be loading a random text file as a config file that has nothing to do with this
        // program. That should be a problem, even though it will register as merely having a bunch of missing values
        return messages.size() > MAX_NUMBER_OF_NON_PROBLEMS;
    }

    /**
     * Get whether all the load problems are just missing keys
     * 
     * @return whether all errors are missing keys
     */
    public boolean isOnlyMissingKeys()
    {
        for (LoadConfigErrorType result : types)
        {
            if (result != LoadConfigErrorType.MISSING_KEY)
            {
                return false;
            }
        }
        return true;
    }

    /**
     * Get the messages
     * 
     * @return messages
     */
    public List<String> getMessages()
    {
        return messages;
    }

    /**
     * Get the types
     * 
     * @return types
     */
    public Set<LoadConfigErrorType> getTypes()
    {
        return types;
    }

    /**
     * Adds all the contents of the specified other report to this report
     * 
     * @param otherReport
     */
    public void addFromReport(LoadConfigReport otherReport)
    {
        for (String msg : otherReport.getMessages())
        {
            messages.add(msg);
        }
        for (LoadConfigErrorType rslt : otherReport.getTypes())
        {
            types.add(rslt);
        }
    }

    public String getMainMessage()
    {
        return mainMessage;
    }

    public void setMainMessage(String mainMessage)
    {
        this.mainMessage = mainMessage;
    }
} public class EmojiJob
{
    private final EmojiType type;

    private final EmojiOperation op;

    private final String oauth;

    /**
     * Can be null if no channel is required
     */
    private final String channel;

    public EmojiJob(String oauth, EmojiType type, EmojiOperation op)
    {
        this(oauth, type, op, null);
    }

    public EmojiJob(String oauth, EmojiType type, EmojiOperation op, String channel)
    {
        this.type = type;
        this.op = op;
        this.oauth = oauth;
        this.channel = channel;
    }

    public String getOauth()
    {
        return oauth;
    }

    public EmojiType getType()
    {
        return type;
    }

    public EmojiOperation getOp()
    {
        return op;
    }

    public String getChannel()
    {
        return channel;
    }

    @Override
    public int hashCode()
    {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((channel == null) ? 0 : channel.hashCode());
        result = prime * result + ((op == null) ? 0 : op.hashCode());
        result = prime * result + ((type == null) ? 0 : type.hashCode());
        return result;
    }

    @Override
    public boolean equals(Object obj)
    {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        EmojiJob other = (EmojiJob) obj;
        if (channel == null)
        {
            if (other.channel != null)
                return false;
        }
        else if (!channel.equals(other.channel))
            return false;
        if (op != other.op)
            return false;
        if (type != other.type)
            return false;
        return true;
    }

    @Override
    public String toString()
    {
        return op.getDescription().substring(0, 1).toUpperCase() + op.getDescription().substring(1) + " " + type.getDescription() + (channel != null ? " for " + channel : "");
    }

} public enum EmojiOperation
{
    LOAD("load"), CACHE("cache");

    private final String description;

    private EmojiOperation(String description)
    {
        this.description = description;
    }

    public String getDescription()
    {
        return description;
    }
} public enum EmojiType
{
    // @formatter:off
    TWITCH_V1("Twitch Emotes from Chat V1", EmojiGroup.TWITCH, false),
 
    TWITCH_BADGE("Twitch Badge", EmojiGroup.TWITCH, true), 
    FRANKERFACEZ_CHANNEL("FrankerFaceZ Emotes", EmojiGroup.FFZ, false), 
    FRANKERFACEZ_GLOBAL("FrankerFaceZ Global Emotes", EmojiGroup.FFZ, false), 
    FRANKERFACEZ_BADGE("FrankerFaceZ Badge", EmojiGroup.FFZ, true),
    FRANKERFACEZ_REPLACEMENT("FrankerFaceZ Replacement Emotes", EmojiGroup.FFZ, false),  
    BETTER_TTV_CHANNEL("Better Twitch TV Emotes", EmojiGroup.BTTV, false), 
    BETTER_TTV_GLOBAL("Better Twitch TV Global Emotes", EmojiGroup.BTTV, false), 
    TWITTER_EMOJI("Twitter Unicode Emotes", EmojiGroup.UNICODE, false);
    // @formatter:on

    private final EmojiGroup group;

    private final boolean badge;

    private final String description;

    /**
     * These are the types of emoji words to check against for manual messages.
     */
    public static EmojiType[] MANUAL_EMOJI_TYPES = new EmojiType[] { EmojiType.FRANKERFACEZ_CHANNEL, EmojiType.FRANKERFACEZ_GLOBAL, EmojiType.BETTER_TTV_CHANNEL, EmojiType.BETTER_TTV_GLOBAL, EmojiType.TWITTER_EMOJI };

    /**
     * These are the types of emoji words in messages are checked against to include. This is done to prevent things
     * like badges from accidentally being included. For example, FFZ badges are included in their maps using keywords
     * like "bot", which could easily be accidentally inserted into a message.
     */
    public static EmojiType[] THIRD_PARTY_EMOJI_TYPES = new EmojiType[] { EmojiType.FRANKERFACEZ_CHANNEL, EmojiType.FRANKERFACEZ_GLOBAL, EmojiType.BETTER_TTV_CHANNEL, EmojiType.BETTER_TTV_GLOBAL, EmojiType.TWITTER_EMOJI };

    private EmojiType(String description, EmojiGroup group, boolean badge)
    {
        this.group = group;
        this.description = description;
        this.badge = badge;
    }

    public boolean isTwitchEmote()
    {
        return group == EmojiGroup.TWITCH;
    }

    public boolean isFrankerFaceZEmote()
    {
        return group == EmojiGroup.FFZ;
    }

    public boolean isBetterTtvEmote()
    {
        return group == EmojiGroup.BTTV;
    }

    public boolean isBadge()
    {
        return badge;
    }

    public String getDescription()
    {
        return description;
    }
} public class ChatWindow extends JFrame
{
    private static final long serialVersionUID = 1L;

    /**
     * A stored reference to the chat panel, used whenever some part of the system has a reference to this ChatWindow,
     * but needs to modify something on the panel. This is in lieu of putting a bunch of accessor methods here to do
     * pass through the function to the panel in an encapsulated way.
     */
    private ChatPanel chatPanel;

    /**
     * A static copy of this ChatWindow for accessing it globally
     */
    public static ChatWindow me;

    /**
     * The popup for submitting errors that the user needs to see
     */
    public static FontificatorError popup;

    /**
     * Mouse listeners for dragging the Chat Window around when dragging the mouse inside the chat
     */
    private ChatMouseListeners mouseListeners;

    /**
     * Escape stroke to close popups
     */
    private static final KeyStroke escapeStroke = KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0);

    /**
     * Construct the Chat Window
     */
    public ChatWindow()
    {
        me = this;
        setTitle("Fontificator Chat");
        popup = new FontificatorError(null);
    }

    /**
     * Sets the properties to get hooks into the properties' configuration models; Sets the ControlWindow to get hooks
     * back into the controls; Sets the loaded member Boolean to indicate it has everything it needs to begin rendering
     * the visualization
     * 
     * @param fProps
     * @param ctrlWindow
     * @throws IOException
     */
    public void initChat(final FontificatorProperties fProps, final ControlWindow ctrlWindow) throws IOException
    {
        chatPanel = new ChatPanel();
        add(chatPanel);

        mouseListeners = new ChatMouseListeners(this, ctrlWindow);
        addMouseListener(mouseListeners);
        addMouseMotionListener(mouseListeners);
        addMouseWheelListener(chatPanel);
        setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);

        setChatSize(fProps.getChatConfig());

        setResizable(fProps.getChatConfig().isResizable());
        setAlwaysOnTop(fProps.getChatConfig().isAlwaysOnTop());

        chatPanel.setConfig(fProps);
        chatPanel.initExpirationTimer();

        addWindowListener(new WindowListener()
        {
            @Override
            public void windowOpened(WindowEvent e)
            {
            }

            @Override
            public void windowClosing(WindowEvent e)
            {
                callExit(e.getComponent());
            }

            @Override
            public void windowClosed(WindowEvent e)
            {
            }

            @Override
            public void windowIconified(WindowEvent e)
            {
            }

            @Override
            public void windowDeiconified(WindowEvent e)
            {
            }

            @Override
            public void windowActivated(WindowEvent e)
            {
            }

            @Override
            public void windowDeactivated(WindowEvent e)
            {
            }

            /**
             * Calls exit from the control window
             */
            private void callExit(Component caller)
            {
                ctrlWindow.attemptToExit(caller);
            }
        });

    }

    /**
     * Handles sizing the Chat Window, clearing out old JFrame-based config values in the ConfigChat model if they were
     * the only ones available
     * 
     * @param chatConfig
     */
    public void setChatSize(ConfigChat chatConfig)
    {
        if (chatConfig.getWindowWidth() != null && chatConfig.getWindowHeight() != null)
        {
            setSize(chatConfig.getWindowWidth(), chatConfig.getWindowHeight());
            chatConfig.setWidth(getContentPane().getWidth());
            chatConfig.setHeight(getContentPane().getHeight());
            chatConfig.clearLegacyWindowSize();
        }
        else if (chatConfig.getWidth() > 0 && chatConfig.getHeight() > 0)
        {
            getContentPane().setPreferredSize(new Dimension(chatConfig.getWidth(), chatConfig.getHeight()));
            getContentPane().setSize(chatConfig.getWidth(), chatConfig.getHeight());
            pack();
        }
    }

    /**
     * Does the work required to make the parameter JDialog be hidden when pressing escape
     * 
     * @param popup
     */
    public static void setupHideOnEscape(final JDialog popup)
    {
        Action aa = new AbstractAction()
        {
            private static final long serialVersionUID = 1L;

            public void actionPerformed(ActionEvent event)
            {
                popup.setVisible(false);
            }
        };
        final String mapKey = "escapePressed";
        JRootPane root = popup.getRootPane();
        root.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(escapeStroke, mapKey);
        root.getActionMap().put(mapKey, aa);
    }

    /**
     * Access to the chat panel through this window, for any places there is a reference to just the ChatWindow, but
     * that needs to affect the ChatPanel, which has all the actual chat options
     * 
     * @return chatPanel
     */
    public ChatPanel getChatPanel()
    {
        return chatPanel;
    }

    /**
     * This is a small hack to expose the ctrl window to the message control panel so its username case map can be
     * cleared out if the user changes the type of username case resolution
     */
    public void clearUsernameCases()
    {
        mouseListeners.clearUsernameCases();
    }

} public class LabeledSlider extends JPanel
{
    private static final long serialVersionUID = 1L;

    private static final float NO_SCALE = 1.0f;

    protected JSlider slider;

    private JLabel label;

    private String unitLabelStr;

    private JLabel unitLabel;

    private int maxValueDigits;

    private float scale;

    public LabeledSlider(String labelStr, String unitLabelStr, int min, int max)
    {
        this(labelStr, unitLabelStr, min, max, Math.max(Integer.toString(min).length(), Integer.toString(max).length()));
    }

    public LabeledSlider(String labelStr, String unitLabelStr, int min, int max, float scale)
    {
        this(labelStr, unitLabelStr, min, max, Math.max(Integer.toString(min).length(), Integer.toString(max).length()), scale);
    }

    public LabeledSlider(String labelStr, String unitLabelStr, int min, int max, int maxValueDigits)
    {
        this(labelStr, unitLabelStr, min, max, min, maxValueDigits);
    }

    public LabeledSlider(String labelStr, String unitLabelStr, int min, int max, int maxValueDigits, float scale)
    {
        this(labelStr, unitLabelStr, min, max, min, maxValueDigits, scale);
    }

    public LabeledSlider(String labelStr, String unitLabelStr, int min, int max, int value, int maxValueDigits)
    {
        this(labelStr, unitLabelStr, min, max, value, maxValueDigits, NO_SCALE);
    }

    public LabeledSlider(String labelStr, String unitLabelStr, int min, int max, int value, int maxValueDigits, float scale)
    {
        this.scale = scale;
        this.unitLabelStr = unitLabelStr;
        this.slider = new JSlider(JSlider.HORIZONTAL, min, max, value);
        this.label = new JLabel(labelStr);
        this.unitLabel = new JLabel(slider.getValue() + " " + unitLabelStr);
        this.maxValueDigits = maxValueDigits;
        setUnitLabel();
        slider.addChangeListener(new ChangeListener()
        {
            public void stateChanged(ChangeEvent e)
            {
                setUnitLabel();
            }
        });
        addComponents();
    }

    protected void addComponents()
    {
        setLayout(new GridBagLayout());
        GridBagConstraints gbc = new GridBagConstraints(0, 0, 1, 1, 1.0, 0.0, GridBagConstraints.CENTER, GridBagConstraints.NONE, new Insets(0, 0, 0, 0), 0, 0);

        gbc.weightx = 0.0;
        gbc.weighty = 0.0;
        gbc.anchor = GridBagConstraints.EAST;
        gbc.gridx = 0;
        gbc.gridy = 0;

        add(label, gbc);

        gbc.weightx = 1.0;
        gbc.anchor = GridBagConstraints.CENTER;
        gbc.gridx++;

        gbc.fill = GridBagConstraints.HORIZONTAL;
        add(slider, gbc);
        gbc.fill = GridBagConstraints.NONE;

        gbc.weightx = 0.0;
        gbc.anchor = GridBagConstraints.WEST;
        gbc.gridx++;

        add(unitLabel, gbc);
    }

    public void setEnabled(boolean enabled)
    {
        slider.setEnabled(enabled);
        label.setEnabled(enabled);
        unitLabel.setEnabled(enabled);
    }

    public int getValue()
    {
        return slider.getValue();
    }

    public float getScaledValue()
    {
        return getValue() * scale;
    }

    public String getValueString()
    {
        if (scale == NO_SCALE)
        {
            return Integer.toString(getValue());
        }
        else
        {
            return String.format("%.2f", getScaledValue());
        }
    }

    protected void setUnitLabel()
    {
        unitLabel.setText("<html><nobr><tt><b>" + padValue(getValueString()) + " " + getUnitLabelStr() + "</b></tt></nobr></html>");
    }

    private String padValue(String valStr)
    {
        return padValue(valStr, maxValueDigits);
    }

    protected String padValue(String valStr, int length)
    {
        while (valStr.length() < length)
        {
            valStr = " " + valStr;
        }
        valStr = valStr.replaceAll(" ", "&nbsp;");
        return valStr;
    }

    public void addChangeListener(ChangeListener cl)
    {
        slider.addChangeListener(cl);
    }

    public void setValue(int value)
    {
        slider.setValue(value);
    }

    public void setScaledValue(float value)
    {
        setValue((int) (value / scale));
    }

    public JSlider getSlider()
    {
        return slider;
    }

    public void setValueTextColor(Color textColor)
    {
        unitLabel.setForeground(textColor);
    }

    protected String getUnitLabelStr()
    {
        return unitLabelStr;
    }
} public class EmojiLoadProgressPanel extends JPanel
{
    private static final Logger logger = Logger.getLogger(EmojiLoadProgressPanel.class);

    private static final long serialVersionUID = 1L;

    /**
     * 4 spaces for the number of characters in "100%", the widest it will display
     */
    private static final String EMPTY_VALUE_TEXT = "    ";

    /**
     * Visually shows the progress
     */
    private JProgressBar bar;

    /**
     * Displays the percentage on the bar
     */
    private JLabel percentValue;

    /**
     * The cancel button for terminating the current work task
     */
    private JButton cancelButton;

    /**
     * Button for loading and caching everything. This is intended as a catch-all for any trouble encountered while
     * loading or caching emotes or badges.
     */
    private JButton manualButton;

    /**
     * The reset button for setting all emoji work (loads and caches) back to zero
     */
    private JButton resetButton;

    /**
     * A log box to document and display all the emote loading
     */
    private LogBox emojiLogBox;

    /**
     * A reference to the chat panel to repaint once loading is complete
     */
    private ChatPanel chat;

    /**
     * A reference to the emoji config object to set when things are loaded or cached or reset
     */
    private ConfigEmoji emojiConfig;

    /**
     * List of load tasks to be executed in series
     */
    private ConcurrentLinkedQueue<EmojiWorker> workerTaskListLoad;

    /**
     * List of cache tasks to be executed in series, after all the loading is done
     */
    private ConcurrentLinkedQueue<EmojiWorker> workerTaskListCache;

    /**
     * The cursor for the emoji worker task currently being run
     */
    private EmojiWorker currentWorker;

    private final ControlPanelEmoji emojiControlPanel;

    /**
     * Construct the emote loading/caching progress panel that sits on the bottom of the Emoji tab of the Control Window
     * 
     * @param chat
     *            The chat panel, for repainting after a load
     * @param emojiControlPanel
     *            Used by the reload button
     */
    public EmojiLoadProgressPanel(ChatPanel chatPanel, final ControlPanelEmoji emojiControlPanel)
    {
        this.chat = chatPanel;

        this.emojiControlPanel = emojiControlPanel;

        this.workerTaskListLoad = new ConcurrentLinkedQueue<EmojiWorker>();
        this.workerTaskListCache = new ConcurrentLinkedQueue<EmojiWorker>();
        this.currentWorker = null;
        this.emojiLogBox = new LogBox();

        this.bar = new JProgressBar(JProgressBar.HORIZONTAL, 0, 100);
        this.percentValue = new JLabel(EMPTY_VALUE_TEXT);
        this.percentValue.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
        this.cancelButton = new JButton("Cancel");
        this.cancelButton.setToolTipText("Cancel any emoji or badge loading or caching currently running or queued to run");
        this.manualButton = new JButton("Load/Cache");
        this.manualButton.setToolTipText("Manually load and or cache all emoji and badges");
        this.resetButton = new JButton("Reset");
        this.resetButton.setToolTipText("Reset all work done loading and or caching emoji and badges");
        handleButtonEnables();

        ActionListener bal = new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                JButton source = (JButton) e.getSource();
                if (cancelButton.equals(source))
                {
                    if (currentWorker != null)
                    {
                        reset();
                    }
                }
                else if (resetButton.equals(source))
                {
                    workerTaskListLoad.clear();
                    workerTaskListCache.clear();
                    emojiConfig.resetWorkCompleted();
                    log("Reset all loaded and or cached emoji");
                    chat.repaint();
                }
                else if (manualButton.equals(source))
                {
                    reset();
                    emojiControlPanel.loadAndRunEmojiWork();
                }
                handleButtonEnables();
            }
        };

        this.cancelButton.addActionListener(bal);
        this.resetButton.addActionListener(bal);
        this.manualButton.addActionListener(bal);

        setLayout(new GridBagLayout());

        GridBagConstraints gbc = ControlPanelBase.getGbc();
        gbc.fill = GridBagConstraints.NONE;
        gbc.gridx = 0;
        gbc.gridy = 0;
        JPanel workPanel = new JPanel(new GridBagLayout());
        gbc.fill = GridBagConstraints.BOTH;
        gbc.weightx = 1.0;
        workPanel.add(this.bar, gbc);
        gbc.gridx++;
        gbc.weightx = 0.0;
        gbc.fill = GridBagConstraints.VERTICAL;
        workPanel.add(percentValue, gbc);
        gbc.gridx++;
        workPanel.add(this.cancelButton, gbc);
        gbc.gridx++;
        workPanel.add(this.manualButton, gbc);
        gbc.gridx++;
        workPanel.add(this.resetButton, gbc);

        gbc = ControlPanelBase.getGbc();
        gbc.fill = GridBagConstraints.BOTH;
        gbc.weightx = 1.0;
        gbc.weighty = 1.0;
        add(emojiLogBox, gbc);
        gbc.gridy++;

        gbc.weighty = 0.0;
        gbc.fill = GridBagConstraints.HORIZONTAL;
        add(workPanel, gbc);
    }

    synchronized public boolean isCurrentlyRunning()
    {
        return currentWorker != null;
    }

    synchronized public void handleButtonEnables()
    {
        cancelButton.setEnabled(isCurrentlyRunning());
        resetButton.setEnabled(emojiConfig != null && emojiConfig.isAnyWorkDone());
        final int remainingJobs = emojiControlPanel.countJobs();
        manualButton.setEnabled(remainingJobs > 0 && currentWorker == null && workerTaskListLoad.isEmpty() && workerTaskListCache.isEmpty());

        // @formatter:off
        logger.trace("Cancel: " + cancelButton.isEnabled() + " b/c currWork " + (currentWorker == null ? "is null" : "is NOT null") + 
        "; Reset: " + resetButton.isEnabled() + " b/c " + (emojiConfig != null && emojiConfig.isAnyWorkDone() ? "some work is done" : "no work has been done") + 
        "; Manual: " + manualButton.isEnabled() + " b/c there " + (remainingJobs == 1 ? "is" : "are") + " " + remainingJobs + " job" + (remainingJobs == 1 ? "" : "s") + " left undone and " + 
        (currentWorker == null && workerTaskListLoad.isEmpty() && workerTaskListCache.isEmpty() ? "nothing" : "something") + " is currently running or is queued");
        // @formatter:on
    }

    /**
     * Must be called before using this progress panel
     * 
     * @param emojiConfig
     */
    public void setEmojiConfig(ConfigEmoji emojiConfig)
    {
        this.emojiConfig = emojiConfig;
    }

    /**
     * Takes a report and updates all the display components with the appropriate information
     * 
     * @param report
     */
    synchronized public void update(EmojiWorkerReport report)
    {
        if (report.isCanceled())
        {
            emojiLogBox.log(report.getMessage());
            blankAllValues();
            reset();
            handleButtonEnables();
        }
        else if (report.isError())
        {
            emojiLogBox.log(report.getMessage());
            blankAllValues();

            if (currentWorker != null)
            {
                currentWorker.haltCurrentJob();
            }
            currentWorker = null;
            initiateNextWork();
        }
        else
        {
            bar.setValue(report.getPercentComplete());
            percentValue.setText(report.getPercentText());

            if (report.isComplete())
            {
                emojiLogBox.log(report.getMessage());
                if (currentWorker != null)
                {
                    emojiConfig.setWorkCompleted(currentWorker.getEmojiJob());
                }
                initiateNextWork();
            }
        }
        repaint();
    }

    /**
     * Reverts the panel to its ready state
     */
    synchronized private void reset()
    {
        cancelButton.setEnabled(false);
        blankAllValues();
        workerTaskListLoad.clear();
        workerTaskListCache.clear();
        currentWorker = null;
    }

    /**
     * Removes all labels and values from the display
     */
    synchronized private void blankAllValues()
    {
        percentValue.setText(EMPTY_VALUE_TEXT);
        bar.setValue(0);
    }

    /**
     * Call to add work
     * 
     * @param label
     * @param emojiWorker
     * @param initialReport
     */
    synchronized public void addWorkToQueue(EmojiWorker emojiWorker)
    {
        ConcurrentLinkedQueue<EmojiWorker> taskList = getTaskList(emojiWorker.getEmojiJob());
        for (EmojiWorker worker : taskList)
        {
            if (!worker.isCancelled() && worker.getEmojiJob().equals(emojiWorker.getEmojiJob()))
            {
                // Already exists in a non-canceled way, so don't add it
                return;
            }
        }
        taskList.add(emojiWorker);
    }

    /**
     * Call to initiate work
     */
    synchronized public void initiateWork()
    {
        ConcurrentLinkedQueue<EmojiWorker> taskList = getTaskList();
        if (!taskList.isEmpty())
        {
            currentWorker = taskList.poll();
            setLocation(getParent().getLocation().x + (getParent().getWidth() - getWidth()) / 2, getParent().getLocation().y + (getParent().getHeight() - getHeight()) / 2);
            cancelButton.setEnabled(true);
            update(currentWorker.getInitialReport());
            currentWorker.execute();
        }
    }

    /**
     * Kick off the next worker when the current worker is done
     */
    synchronized private void initiateNextWork()
    {
        this.percentValue.setText(EMPTY_VALUE_TEXT);

        ConcurrentLinkedQueue<EmojiWorker> taskList = getTaskList();
        this.currentWorker = taskList.poll();

        chat.repaint();
        if (currentWorker == null)
        {
            reset();
            handleButtonEnables();
        }
        else
        {
            currentWorker.execute();
        }
    }

    /**
     * Take any worker containing a job that matches the specified job off the queue of workers to be executed. Also
     * cancels the currently running worker if it matches.
     * 
     * @param job
     */
    synchronized public void removeWorkFromQueue(EmojiJob job)
    {
        if (currentWorker != null)
        {
            if (jobMatch(job, currentWorker))
            {
                logger.trace("Canceling " + job.toString());
                currentWorker.haltCurrentJob();
            }
        }

        ConcurrentLinkedQueue<EmojiWorker> taskList = getTaskList(job);
        for (EmojiWorker worker : taskList)
        {
            if (jobMatch(job, worker))
            {
                logger.trace("Canceling " + job.toString());
                worker.haltCurrentJob();
                // Is this thread safe?
                workerTaskListLoad.remove(worker);
            }
        }
    }

    /**
     * Get a task list (load or cache) based on job op type
     * 
     * @param job
     * @return task list
     */
    synchronized private ConcurrentLinkedQueue<EmojiWorker> getTaskList(EmojiJob job)
    {
        return job.getOp() == EmojiOperation.CACHE ? workerTaskListCache : workerTaskListLoad;
    }

    /**
     * Get a task list (load or cache) based on whether the load list is empty
     * 
     * @return task list
     */
    synchronized private ConcurrentLinkedQueue<EmojiWorker> getTaskList()
    {
        return workerTaskListLoad.isEmpty() ? workerTaskListCache : workerTaskListLoad;
    }

    synchronized private boolean jobMatch(EmojiJob job, EmojiWorker worker)
    {
        return worker != null && worker.getEmojiJob() != null && !worker.isDone() && !worker.isCancelled() && worker.getEmojiJob().equals(job);
    }

    public void log(String msg)
    {
        emojiLogBox.log(msg);
    }
} public class EmojiWorker extends SwingWorker<Integer, EmojiWorkerReport>
{
    private static final Logger logger = Logger.getLogger(EmojiWorker.class);

    /**
     * Hard-coded URL for the Twitch prime (premium) badge, because it is not added to the channel badge API as of
     * 2016-10-04
     */
    private static final String TWITCH_BADGE_PRIME = "https://static-cdn.jtvnw.net/badges/v1/a1dd5073-19c3-4911-8cb4-c464a7bc1510/1";

    /**
     * The base URL for getting just the room data from the FrankerFaceZ API, without any emote data
     */
    private static final String FFZ_BASE_NO_EMOTES_URL = "https://api.frankerfacez.com/v1/_room/";

    // @formatter:off

    /**
     * V1 Twitch emote IDs for the global emotes, last updated 2015 November 30
     */
    private static final Integer[] TWITCH_EMOTE_IDS_GLOBAL = new Integer[] {
        27, 15, 25, 21, 19, 22, 18, 
        29, 28, 17, 16, 26, 24, 20, 
        33, 30, 45, 38, 40, 44, 34, 
        31, 41, 42, 39, 36, 32, 37, 
        46, 69, 47, 50, 70, 66, 51, 
        48, 67, 49, 52, 68, 65, 71, 
        88, 72, 169, 91, 166, 74, 87, 
        167, 73, 170, 92, 86, 168, 90, 
        171, 361, 357, 354, 243, 881, 244, 
        358, 355, 245, 359, 360, 356, 973, 
        1896, 1904, 1900, 1905, 1897, 3632, 1901, 
        1906, 1902, 3666, 1898, 3412, 1903, 1899, 
        7427, 3668, 4337, 9804, 9803, 9800, 3792, 
        4338, 4057, 4339, 9801, 4240, 9802, 5253, 
        9805, 27679, 9809, 22998, 9806, 27509, 27903, 
        14706, 20225, 28087, 9807, 9808, 22639, 27602, 
        28328, 35063, 46881, 30134, 38436, 47005, 30259, 
        46248, 38586, 46249, 47007, 32035, 44499, 34875, 
        47008, 56879, 47010, 49106, 54089, 50901, 56880, 
        47011, 51838, 54090, 47302, 52492, 47420, 55338, 
        56881, 70433, 62835, 58765, 58127, 62836, 62833, 
        68856, 58135, 56882, 58136, 56883, 64138, 62834, 
        72748, 72752, 72753, 72751, 72750, 72749
    };

    /**
     * Word keys for Twitch global emotes, last updated 2015 November 30
     */
    private static final String[] TWITCH_EMOTES_GLOBAL = new String[] { 
        "JKanStyle", "OptimizePrime", "StoneLightning", "TheRinger", "PazPazowitz", "EagleEye", "CougarHunt", 
        "RedCoat", "BionicBunion", "Kappa", "JonCarnage", "PicoMause", "MrDestructoid", "MVGame", 
        "BCWarrior", "SuperVinlin", "GingerPower", "DansGame", "SwiftRage", "PJSalt", "StrawBeary", 
        "BlargNaut", "FreakinStinkin", "KevinTurtle", "Kreygasm", "FPSMarksman", "NoNoSpot", "NinjaTroll", 
        "SSSsss", "PunchTrees", "FunRun", "UleetBackup", "ArsonNoSexy", "SMSkull", "SMOrc", 
        "FrankerZ", "OneHand", "TinyFace", "HassanChop", "BloodTrail", "TheTarFu", "UnSane", 
        "EvilFetus", "DBstyle", "AsianGlow", "BibleThump", "ShazBotstix", "PogChamp", "Jebaited", 
        "OMGScoots", "PMSTwin", "Volcania", "WinWaker", "FuzzyOtterOO", "ItsBoshyTime", "DatSheffy", 
        "TriHard", "BORT", "FUNgineer", "ResidentSleeper", "4Head", "SoonerLater", "OpieOP", 
        "HotPokket", "Poooound", "TooSpicy", "FailFish", "RuleFive", "BrainSlug", "DAESuppy", 
        "WholeWheat", "WTRuck", "ThunBeast", "TF2John", "RalpherZ", "Kippa", "Keepo", 
        "DogFace", "BigBrother", "BatChest", "SoBayed", "PeoplesChamp", "GrammarKing", "UncleNox", 
        "PanicVis", "ANELE", "BrokeBack", "PipeHype", "YouWHY", "RitzMitz", "EleGiggle", 
        "KZskull", "TheThing", "AtIvy", "AtWW", "GasJoker", "KAPOW", "MechaSupes", 
        "NightBat", "shazamicon", "Shazam", "PJHarley", "AtGL", "SriHead", "DatHass", 
        "BabyRage", "panicBasket", "PermaSmug", "BuddhaBar", "RaccAttack", "ShibeZ", "WutFace", 
        "PRChase", "Mau5", "HeyGuys", "AthenaPMS", "NotATK", "mcaT", "TTours", 
        "PraiseIt", "tbBaconBiscuit", "deIlluminati", "deExcite", "HumbleLife", "OSbeaver", "OSdeo", 
        "OSfrog", "OSkomodo", "OSsloth", "OSrob", "OSbury", "CorgiDerp", "TheKing", 
        "ArgieB8", "ShadyLulu", "DOOMGuy", "VaultBoy", "KappaPride", "tbChickenBiscuit", "tbSriracha", 
        "tbSausageBiscuit", "tbSpicy", "tbQuesarito", "CoolCat", "DendiFace", "PuppeyFace", "NotLikeThis", 
        "riPepperonis", "duDudu", "bleedPurple", "twitchRaid", "SeemsGood", "MingLee", "KappaRoss", 
        "PeteZaroll", "PeteZarollTie", "Stormtrooper", "Sullustan", "Ackbar", "ItsATrap"
    };

    /**
     * V1 Twitch emote IDs for the basic emotes (not turbo), last updated 2015 November 30
     */
    private static final Integer[] TWITCH_EMOTE_IDS_BASIC = new Integer[] {
        1, 12, 7, 2, 3, 13, 8, 
        9, 4, 14, 5, 10, 11, 6
    };

    /**
     * Regular expression keys for Twitch basic emotes, last updated 2015 November 30
     */
    private static final String[] TWITCH_EMOTES_BASIC = new String[] { 
        "B-?\\)", "\\:-?[z|Z|\\|]", "\\:-?\\)", "\\:-?\\(", 
        "\\:-?(p|P)", "\\;-?(p|P)", 
        "\\<3", // Modified from Twitch Emote API V3, because they have it wrong
        "\\;-?\\)", 
        "R-?\\)", "[o|O](_|\\.)[o|O]", "\\:-?D", "\\:-?(o|O)", 
        "\\>\\;\\(", "\\:-?[\\\\/]"
    };

    // @formatter:on

    private EmojiManager manager;

    private EmojiApiLoader loader;

    private EmojiParser parser;

    private EmojiJob job;

    private boolean terminateWork;

    private boolean silentlyTerminateWork;

    private EmojiLoadProgressPanel progressPanel;

    private final EmojiWorkerReport initialReport;

    /**
     * Construct an emoji worker
     * 
     * @param manager
     * @param progressPanel
     * @param channel
     * @param emojiType
     * @param opType
     * @param logBox
     * @param initialReport
     */
    public EmojiWorker(EmojiManager manager, EmojiLoadProgressPanel progressPanel, EmojiJob job, LogBox logBox, EmojiWorkerReport initialReport)
    {
        this.terminateWork = false;
        this.silentlyTerminateWork = false;

        this.manager = manager;
        this.progressPanel = progressPanel;
        this.job = job;
        this.initialReport = initialReport;
        logBox.log(initialReport.getMessage());

        loader = new EmojiApiLoader();
        parser = new EmojiParser(logBox);
    }

    @Override
    protected Integer doInBackground() throws Exception
    {
        if (job.getType() == null || job.getOp() == null)
        {
            return Integer.valueOf(1);
        }

        try
        {
            terminateWork = false;

            final EmojiOperation opType = job.getOp();
            final EmojiType emojiType = job.getType();
            final String channel = job.getChannel();
            final String oauth = job.getOauth();

            if (EmojiOperation.LOAD == opType)
            {
                // The proper load for the emoji
                loader.prepLoad(emojiType, channel, oauth);
                String data = runLoader(emojiType);
                if (data != null)
                {
                    parser.putJsonEmojiIntoManager(manager, emojiType, data);
                }

                // Some custom loading required for custom FFZ moderator badges
                if (emojiType == EmojiType.FRANKERFACEZ_BADGE)
                {
                    loader.prepLoad(FFZ_BASE_NO_EMOTES_URL + channel);
                    String ffzRoomJson = runLoader(emojiType);
                    parser.parseFrankerFaceZModBadge(manager, ffzRoomJson);
                }

                // Some custom loading required for the Twitch Prime badge
                if (emojiType == EmojiType.TWITCH_BADGE)
                {
                    TypedEmojiMap tbMap = manager.getEmojiByType(EmojiType.TWITCH_BADGE);
                    tbMap.put("prime", new LazyLoadEmoji("prime", TWITCH_BADGE_PRIME, EmojiType.TWITCH_BADGE));
                }

                Thread.sleep(1L);
            }
            else if (EmojiOperation.CACHE == opType)
            {
                List<String> regexes;
                List<Integer> ids;
                if (emojiType.isTwitchEmote())
                {
                    // This is for Twitch emote V2 or V3. It typically will be whichever is set as the default in the
                    // emoji control panel
                    regexes = new ArrayList<String>(TWITCH_EMOTES_BASIC.length + TWITCH_EMOTES_GLOBAL.length);
                    Collections.addAll(regexes, TWITCH_EMOTES_BASIC);
                    Collections.addAll(regexes, TWITCH_EMOTES_GLOBAL);

                    // This is for Twitch V1 emotes, done by ID. This is how Twitch emotes are actually used, so it
                    // should always be tripped
                    ids = new ArrayList<Integer>(TWITCH_EMOTE_IDS_BASIC.length + TWITCH_EMOTE_IDS_GLOBAL.length);
                    Collections.addAll(ids, TWITCH_EMOTE_IDS_BASIC);
                    Collections.addAll(ids, TWITCH_EMOTE_IDS_GLOBAL);
                }
                else if (emojiType.isFrankerFaceZEmote())
                {
                    Collection<String> ffzChannelRegexes = manager.getEmojiByType(EmojiType.FRANKERFACEZ_CHANNEL).keySet();
                    Collection<String> ffzGlobalRegexes = manager.getEmojiByType(EmojiType.FRANKERFACEZ_GLOBAL).keySet();
                    regexes = new ArrayList<String>(ffzChannelRegexes.size() + ffzGlobalRegexes.size());
                    regexes.addAll(ffzChannelRegexes);
                    regexes.addAll(ffzGlobalRegexes);
                    ids = new ArrayList<Integer>(); // Unused for FFZ
                }
                else if (emojiType.isBetterTtvEmote())
                {
                    Collection<String> bttvChannelRegexes = manager.getEmojiByType(EmojiType.BETTER_TTV_CHANNEL).keySet();
                    Collection<String> bttvGlobalRegexes = manager.getEmojiByType(EmojiType.BETTER_TTV_GLOBAL).keySet();
                    regexes = new ArrayList<String>(bttvChannelRegexes.size() + bttvGlobalRegexes.size());
                    regexes.addAll(bttvChannelRegexes);
                    regexes.addAll(bttvGlobalRegexes);
                    ids = new ArrayList<Integer>(); // Unused for BTTV
                }
                else
                {
                    // Nothing to do
                    regexes = new ArrayList<String>();
                    ids = new ArrayList<Integer>();
                }

                publish(new EmojiWorkerReport("Caching " + emojiType.getDescription(), 0));
                Thread.sleep(1L);
                int count = 0;
                List<LazyLoadEmoji> emojiToCache = new ArrayList<LazyLoadEmoji>();
                for (String regex : regexes)
                {
                    LazyLoadEmoji emoji = manager.getEmojiWords(emojiType, regex, null);
                    emojiToCache.add(emoji);
                }
                for (Integer id : ids)
                {
                    LazyLoadEmoji emoji = manager.getEmojiById(id, null, null);
                    emojiToCache.add(emoji);
                }

                for (LazyLoadEmoji emoji : emojiToCache)
                {
                    if (emoji != null)
                    {
                        emoji.cacheImage();
                    }
                    count++;
                    // This is safe from divide by zero exceptions, because we won't be here if emojiToCache is empty
                    int percentComplete = (int) (100.0f * count / emojiToCache.size());
                    publish(new EmojiWorkerReport("Caching " + emojiType.getDescription(), percentComplete));

                    if (terminateWork)
                    {
                        throw new EmojiCancelException();
                    }

                    Thread.sleep(1L);
                }

                publish(new EmojiWorkerReport(emojiType.getDescription() + " caching complete", 100));
                Thread.sleep(1L);
            }

            return Integer.valueOf(0);
        }
        catch (EmojiHaltException e)
        {
            // Don't let it get caught by the generic Exception, send it on up instead
            publish(new EmojiWorkerReport(job.toString() + " halted", 100, false, false, true));
            try
            {
                Thread.sleep(1L);
            }
            catch (Exception sleepException)
            {
                logger.debug("Couldn't sleep for halt", sleepException);
            }
            throw e;
        }
        catch (EmojiCancelException e)
        {
            // Don't let it get caught by the generic Exception, send it on up instead
            publish(new EmojiWorkerReport(job.toString() + " canceled", 100, false, true, false));
            try
            {
                Thread.sleep(1L);
            }
            catch (Exception sleepException)
            {
                logger.debug("Couldn't sleep for cancel", sleepException);
            }
            throw e;
        }
        catch (FileNotFoundException e)
        {
            publish(new EmojiWorkerReport("URL not found trying to " + job.toString(), 100, true, false, true));
            Thread.sleep(1L);
            return Integer.valueOf(2);
        }
        catch (Exception e)
        {
            e.printStackTrace();
            final String errorMsg = "Unknown error trying to " + job.getOp().getDescription() + " " + job.getType().getDescription() + (job.getChannel() != null ? " for channel " + job.getChannel() : "");
            publish(new EmojiWorkerReport(errorMsg, 100, true, false, true));
            Thread.sleep(1L);
            return Integer.valueOf(3);
        }
    }

    /**
     * Gives you back the data from a website, used to get JSON data for emoji, or for loading the FFZ donor list
     * 
     * @param emojiType
     * @return
     * @throws Exception
     */
    private String runLoader(EmojiType emojiType) throws Exception
    {
        String data = null;
        if (loader.initLoad())
        {
            while (!loader.isLoadComplete())
            {
                int percentComplete = loader.loadChunk();
                // Let the thread rest so the main thread can get the publish
                publish(new EmojiWorkerReport("Downloading " + emojiType.getDescription(), percentComplete));
                if (terminateWork)
                {
                    throw new EmojiCancelException();
                }
                else if (silentlyTerminateWork)
                {
                    throw new EmojiHaltException();
                }
                Thread.sleep(1L);
            }
            data = loader.getLoadedJson();
            loader.reset();
            if (terminateWork)
            {
                throw new EmojiCancelException();
            }
            else if (silentlyTerminateWork)
            {
                throw new EmojiHaltException();
            }

            publish(new EmojiWorkerReport("Parsing " + emojiType.getDescription() + " data...", 0));
        }
        else
        {
            logger.debug("EmojiApiLoader run for " + emojiType.getDescription() + " without required call to prepLoad.");
        }
        publish(new EmojiWorkerReport(emojiType.getDescription() + " loading complete", 100));

        loader.reset();

        return data;
    }

    @Override
    protected void process(List<EmojiWorkerReport> reports)
    {
        if (reports != null && !reports.isEmpty())
        {
            EmojiWorkerReport latestReport = reports.get(reports.size() - 1);
            reports.clear();
            progressPanel.update(latestReport);
        }
    }

    public void cancel()
    {
        terminateWork = true;
    }

    public void haltCurrentJob()
    {
        silentlyTerminateWork = true;
    }

    public boolean isWorkTerminatedAlready()
    {
        return terminateWork || silentlyTerminateWork;
    }

    public EmojiWorkerReport getInitialReport()
    {
        return initialReport;
    }

    public EmojiJob getEmojiJob()
    {
        return job;
    }

} public class EmojiWorkerReport
{
    /**
     * The human readable message describing the work
     */
    private final String message;

    /**
     * The percent complete, 0 to 100
     */
    private final int percentComplete;

    /**
     * Indicates an error has occurred
     */
    private final boolean error;

    /**
     * Indicates the work has been canceled
     */
    private final boolean canceled;

    /**
     * Indicates the work has been halted
     */
    private final boolean halted;

    public EmojiWorkerReport(String message, int percentComplete)
    {
        this(message, percentComplete, false, false, false);
    }

    public EmojiWorkerReport(String message, int percentComplete, boolean error, boolean canceled, boolean halted)
    {
        this.message = message;
        this.percentComplete = percentComplete;
        this.error = error;
        this.canceled = canceled;
        this.halted = halted;
    }

    /**
     * Get the human readable message describing the work
     * 
     * @return message
     */
    public String getMessage()
    {
        return message;
    }

    /**
     * Get the percent complete, 0 to 100
     * 
     * @return percentCompete
     */
    public int getPercentComplete()
    {
        return percentComplete;
    }

    /**
     * Get the percent text formatted with leading spaces and a percent sign
     * 
     * @return percentText
     */
    public String getPercentText()
    {
        String ps = Integer.toString(percentComplete);
        while (ps.length() < 3)
        {
            ps = " " + ps;
        }
        return ps + "%";
    }

    public boolean isComplete()
    {
        return percentComplete == 100 && !isError() && !isCanceled();
    }

    public boolean isError()
    {
        return error;
    }

    public boolean isCanceled()
    {
        return canceled;
    }

    public boolean isHalted()
    {
        return halted;
    }

    @Override
    public String toString()
    {
        return "EmojiWorkerReport: " + message + " " + getPercentText();
    }
}
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import javax.swing.BorderFactory;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSeparator;
import javax.swing.SwingConstants;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import com.glitchcog.fontificator.bot.ChatViewerBot;
import com.glitchcog.fontificator.config.ConfigEmoji;
import com.glitchcog.fontificator.config.EmojiLoadingDisplayStragegy;
import com.glitchcog.fontificator.config.FontificatorProperties;
import com.glitchcog.fontificator.config.loadreport.LoadConfigReport;
import com.glitchcog.fontificator.emoji.EmojiJob;
import com.glitchcog.fontificator.emoji.EmojiOperation;
import com.glitchcog.fontificator.emoji.EmojiType;
import com.glitchcog.fontificator.gui.chat.ChatWindow;
import com.glitchcog.fontificator.gui.component.LabeledSlider;
import com.glitchcog.fontificator.gui.emoji.EmojiLoadProgressPanel;
import com.glitchcog.fontificator.gui.emoji.EmojiWorker;
import com.glitchcog.fontificator.gui.emoji.EmojiWorkerReport;

        frankerPanel.setEnabled(all);
        enableFrankerFaceZ.setEnabled(all);
        cacheFrankerFaceZ.setEnabled(all && enableFrankerFaceZ.isSelected());

        betterPanel.setEnabled(all);
        enableBetterTtv.setEnabled(all);
        cacheBetterTtv.setEnabled(all && enableBetterTtv.isSelected());

        progressPanel.handleButtonEnables();
    }

    @Override
    protected void build()
    {
        enableAll = new JCheckBox("Enable Emoji");
        enableAnimation = new JCheckBox("Enable Animation");
        enableTwitchBadges = new JCheckBox("Enable Twitch Badges");
        enableFfzBadges = new JCheckBox("Enable FrankerFaceZ Badges");

        progressPanel = new EmojiLoadProgressPanel(chat, this);

        emojiScaleToLineHeight = new JCheckBox("Relative to Line Height");
        emojiScale = new LabeledSlider("Emoji Scale", "%", ConfigEmoji.MIN_SCALE, ConfigEmoji.MAX_SCALE, 100, 3);

        badgeScaleToLineHeight = new JCheckBox("Relative to Line Height");
        badgeScale = new LabeledSlider("Badge Scale", "%", ConfigEmoji.MIN_SCALE, ConfigEmoji.MAX_SCALE, 100, 3);
        badgeHeightOffset = new LabeledSlider("Badge Height Offset", "pixels", ConfigEmoji.MIN_BADGE_OFFSET, ConfigEmoji.MAX_BADGE_OFFSET, 0, 3);

        emojiLoadingDisplayStratLabel = new JLabel("Loading Display Strategy:");
        emojiLoadingDisplayStrat = new JComboBox<EmojiLoadingDisplayStragegy>(EmojiLoadingDisplayStragegy.values());

        enableTwitterEmoji = new JCheckBox("Enable Twitter Emoji");
        enableTwitterEmoji.setToolTipText("Use Twitter emoji for extended character emoji instead of the local default font");

        enableTwitch = new JCheckBox("Enable Twitch Emotes");
        cacheTwitch = new JCheckBox("Cache Global Twitch Emotes");

        enableFrankerFaceZ = new JCheckBox("Enable FrankerFaceZ Emotes");
        cacheFrankerFaceZ = new JCheckBox("Cache FrankerFaceZ Emotes");

        enableBetterTtv = new JCheckBox("Enable BetterTTV Emotes");
        cacheBetterTtv = new JCheckBox("Cache BetterTTV Emotes");

        emojiScale.addChangeListener(new ChangeListener()
        {
            @Override
            public void stateChanged(ChangeEvent e)
            {
                config.setEmojiScale(emojiScale.getValue());
                chat.repaint();
            }
        });

        badgeScale.addChangeListener(new ChangeListener()
        {
            @Override
            public void stateChanged(ChangeEvent e)
            {
                config.setBadgeScale(badgeScale.getValue());
                chat.repaint();
            }
        });

        badgeHeightOffset.addChangeListener(new ChangeListener()
        {
            @Override
            public void stateChanged(ChangeEvent e)
            {
                config.setBadgeHeightOffset(badgeHeightOffset.getValue());
                chat.repaint();
            }
        });

        emojiLoadingDisplayStrat.addActionListener(new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                config.setDisplayStrategy((EmojiLoadingDisplayStragegy) emojiLoadingDisplayStrat.getSelectedItem());
                chat.repaint();
            }
        });

        enableTwitterEmoji.addActionListener(new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                config.setTwitterEnabled(enableTwitterEmoji.isSelected());
                chat.repaint();
            }
        });

        // This action listener is for loading emotes and badges whenever a checkbox is checked while connected
        ActionListener cbal = new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                JCheckBox source = (JCheckBox) e.getSource();
                if (bot.isConnected())
                {
                    Set<EmojiJob> jobsToRun = new HashSet<EmojiJob>();
                    Set<EmojiJob> jobsToCancel = new HashSet<EmojiJob>();

                    final boolean clickAll = enableAll.equals(source);
                    final boolean clickFfzLoad = clickAll || enableFrankerFaceZ.equals(source);
                    final boolean clickFfzCache = clickAll || cacheFrankerFaceZ.equals(source);
                    final boolean clickBttvLoad = clickAll || enableBetterTtv.equals(source);
                    final boolean clickBttvCache = clickAll || cacheBetterTtv.equals(source);

                    // Badges is independent of enableAll
                    final boolean clickTwitchBadges = !config.isTwitchBadgesLoaded(getConnectChannel()) && enableTwitchBadges.equals(source);
                    final boolean clickFfzBadges = !config.isFfzBadgesLoaded(getConnectChannel()) && enableFfzBadges.equals(source);

                    final String oauth = fProps.getProperty(FontificatorProperties.KEY_IRC_AUTH);

                    if (clickFfzLoad && !config.isFfzLoaded(getConnectChannel()))
                    {
                        EmojiJob jobA = new EmojiJob(oauth, EmojiType.FRANKERFACEZ_CHANNEL, EmojiOperation.LOAD, getConnectChannel());