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 enum MessageType
{
    // @formatter:off
    JOIN(false, " "), 
    ACTION(true, " "), 
    NORMAL(true, ": "), 
    MANUAL(false, ": ");
    // @formatter:on

    /**
     * Whether this type of message should be used to lookup the capitalization of a username
     */
    private final boolean parsableUsername;

    /**
     * The text to place in between the username and the content of the message
     */
    private final String contentBreaker;

    /**
     * Construct message type
     * 
     * @param parsableUsername
     */
    private MessageType(boolean parsableUsername, String contentBreaker)
    {
        this.parsableUsername = parsableUsername;
        this.contentBreaker = contentBreaker;
    }

    /**
     * Get whether this type of message should be used to lookup the capitalization of a username
     * 
     * @return parsableUsername
     */
    public boolean containsParsableUsername()
    {
        return parsableUsername;
    }

    /**
     * Get the text to place in between the username and the content of the message
     * 
     * @return contentBreaker
     */
    public String getContentBreaker()
    {
        return contentBreaker;
    }
} public class TwitchPrivmsg
{
    /**
     * The number of posts for this user this connection session, used to optionally censor first posts that contain
     * URLs in the MessageCensorPanel
     */
    private int postCount;

    /**
     * The color in which to display the username
     */
    private Color color;

    /**
     * The name to display on the message
     */
    private String displayName;

    /**
     * The list of emotes
     */
    private Map<Integer, EmoteAndIndices> emotes;

    /**
     * Whether the user has a Twitch subscription to the channel to which the message is posted
     */
    private boolean subscriber;

    /**
     * Whether the user has a Twitch turbo badge
     */
    private boolean turbo;

    /**
     * Whether the user has a Twitch prime badge
     */
    private boolean prime;

    /**
     * The type of the user, for example channel moderator
     */
    private UserType userType;

    /**
     * The class of message, for example PRIVMSG or WHISPER
     */
    private String messageClassification;

    /**
     * Default constructor that sets default values for everything but displayName
     */
    public TwitchPrivmsg()
    {
        setDefaults();
    }

    /**
     * Constructor sets default values for members other than displayName
     * 
     * @param displayName
     */
    public TwitchPrivmsg(String displayName)
    {
        this.displayName = displayName;
        setDefaults();
    }

    /**
     * Set default values for all the members other than the displayName
     */
    public void setDefaults()
    {
        postCount = 0;
        color = null;
        emotes = new HashMap<Integer, EmoteAndIndices>();
        turbo = false;
        prime = false;
        subscriber = false;
        userType = UserType.NONE;
    }

    /**
     * Get the number of posts for this user this connection session, used to optionally censor first posts that contain
     * URLs in the MessageCensorPanel
     * 
     * @return postCount
     */
    public int getPostCount()
    {
        return postCount;
    }

    /**
     * Increment the number of posts for this user this connection session
     */
    public void incrementPostCount()
    {
        postCount++;
    }

    /**
     * Rest the nubmer of posts to zero for this user, used when a new session is opened
     */
    public void resetPostCount()
    {
        postCount = 0;
    }

    /**
     * Get the color in which to display the username
     * 
     * @return color
     */
    public Color getColor()
    {
        return color;
    }

    /**
     * Set the color in which to display the username
     * 
     * @param color
     */
    public void setColor(Color color)
    {
        this.color = color;
    }

    /**
     * Get the name to display on the message
     * 
     * @return displayName
     */
    public String getDisplayName()
    {
        return displayName;
    }

    /**
     * Set the name to display on the message
     * 
     * @param displayName
     */
    public void setDisplayName(String displayName)
    {
        this.displayName = displayName;
    }

    /**
     * Get the map of emotes, keyed off of character index of the message
     * 
     * @return
     */
    public Map<Integer, EmoteAndIndices> getEmotes()
    {
        return emotes;
    }

    /**
     * Add to the list of emotes
     * 
     * @param emoteSets
     */
    public void addEmote(EmoteAndIndices emoteSet)
    {
        emotes.put(emoteSet.getBegin(), emoteSet);
    }

    /**
     * Get whether the user has a Twitch subscription to the channel to which the message is posted
     * 
     * @return subscriber
     */
    public boolean isSubscriber()
    {
        return subscriber;
    }

    /**
     * Set whether the user has a Twitch subscription to the channel to which the message is posted
     * 
     * @param subscriber
     */
    public void setSubscriber(boolean subscriber)
    {
        this.subscriber = subscriber;
    }

    /**
     * Get whether the user has a Twitch turbo badge
     * 
     * @return turbo
     */
    public boolean isTurbo()
    {
        return turbo;
    }

    /**
     * Set whether the user has a Twitch turbo badge
     * 
     * @param turbo
     */
    public void setTurbo(boolean turbo)
    {
        this.turbo = turbo;
    }

    /**
     * Get whether the user has a Twitch prime badge
     * 
     * @return prime
     */
    public boolean isPrime()
    {
        return prime;
    }

    /**
     * Set whether the user has a Twitch prime badge
     * 
     * @param prime
     */
    public void setPrime(boolean prime)
    {
        this.prime = prime;
    }

    /**
     * Get the type of the user, for example channel moderator
     * 
     * @return userType
     */
    public UserType getUserType()
    {
        return userType;
    }

    /**
     * Set the type of the user, for example channel moderator
     * 
     * @param userType
     */
    public void setUserType(UserType userType)
    {
        this.userType = userType;
    }

    /**
     * Set the messageClassification
     * 
     * @param messageClassification
     */
    public void setMessageClassification(String messageClassification)
    {
        this.messageClassification = messageClassification;
    }

    /**
     * Get whether the message is a PRIVMSG to be displayed in chat, and specifically not a WHISPER message which should
     * not be shown
     * 
     * @return whether the message should be displayed in chat
     */
    public boolean isDisplayMessage()
    {
        return "PRIVMSG".equalsIgnoreCase(messageClassification);
    }

} 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 enum LoadConfigErrorType
{
    // @formatter:off
    FILE_NOT_FOUND(true), 
    MISSING_KEY(false), 
    MISSING_VALUE(false), 
    VALUE_OUT_OF_RANGE(true), 
    PARSE_ERROR_INT(true), 
    PARSE_ERROR_FLOAT(true), 
    PARSE_ERROR_BOOL(true), 
    PARSE_ERROR_CHAR(true), 
    PARSE_ERROR_COLOR(true), 
    PARSE_ERROR_STRING(true), 
    PARSE_ERROR_ENUM(true), 
    UNKNOWN_ERROR(true);
    // @formatter:on

    /**
     * Whether the error represents a problem that renders the configuration unusable
     */
    private final boolean problem;

    /**
     * Construct a LoadConfigErrorType enum
     * 
     * @param error
     *            Whether the error represents a problem that renders the configuration unusable
     */
    private LoadConfigErrorType(boolean error)
    {
        this.problem = error;
    }

    /**
     * Get whether the error represents a problem that renders the configuration unusable
     * 
     * @return problem
     */
    public boolean isProblem()
    {
        return problem;
    }
} 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 AssetIndexLoader
{
    private static final Logger logger = Logger.getLogger(AssetIndexLoader.class);

    private static final String INDEX_FONTS_FILENAME = "index_fonts.txt";

    private static final String INDEX_BORDERS_FILENAME = "index_borders.txt";

    private static final String INDEX_PRESETS_FILENAME = "index_presets.txt";

    private static final String PRESET_DIRECTORY = "presets/";

    /**
     * Loads all the font index values into a map
     * 
     * @return
     */
    public static Map<DropdownLabel, DropdownFont> loadFonts()
    {
        Map<DropdownLabel, DropdownFont> fontMap = new LinkedHashMap<DropdownLabel, DropdownFont>();
        fontMap.put(ControlPanelFont.CUSTOM_KEY, null);
        String errorLine = null;
        try
        {
            List<String> fontLines = loadIndexFile(INDEX_FONTS_FILENAME);
            for (String line : fontLines)
            {
                errorLine = line;
                if (line.trim().startsWith("#"))
                {
                    continue;
                }
                String[] splitLine = line.split(",");
                final String category = splitLine[0].trim();
                final String name = splitLine[1].trim();
                final String filename = splitLine[2].trim();
                final FontType type = FontType.valueOf(splitLine[3].trim());
                fontMap.put(new DropdownLabel(category, name), new DropdownFont(filename, type));
            }
        }
        catch (Exception e)
        {
            logger.error("Error loading font index" + (errorLine == null ? "" : "\n" + errorLine), e);
        }
        return fontMap;
    }

    /**
     * Load all the border index values into a map
     * 
     * @return
     */
    public static Map<DropdownLabel, DropdownBorder> loadBorders()
    {
        Map<DropdownLabel, DropdownBorder> borderMap = new LinkedHashMap<DropdownLabel, DropdownBorder>();
        borderMap.put(ControlPanelFont.CUSTOM_KEY, null);
        String errorLine = null;
        try
        {
            List<String> borderLines = loadIndexFile(INDEX_BORDERS_FILENAME);
            for (String line : borderLines)
            {
                errorLine = line;
                if (line.trim().startsWith("#"))
                {
                    continue;
                }
                String[] splitLine = line.split(",");
                final String category = splitLine[0].trim();
                final String name = splitLine[1].trim();
                final String filename = splitLine[2].trim();
                final int color = Integer.parseInt(splitLine[3].trim(), 16);
                borderMap.put(new DropdownLabel(category, name), new DropdownBorder(filename, color));
            }
        }
        catch (Exception e)
        {
            logger.error("Error loading border index" + (errorLine == null ? "" : "\n" + errorLine), e);
        }
        return borderMap;
    }

    public static Map<String, List<String[]>> loadPresets()
    {
        final Map<String, List<String[]>> presetMapSubmenuToItem = new LinkedHashMap<String, List<String[]>>();

        String errorLine = null;
        try
        {
            List<String> presetLines = loadIndexFile(INDEX_PRESETS_FILENAME);
            for (String line : presetLines)
            {
                errorLine = line;
                if (line.trim().startsWith("#"))
                {
                    continue;
                }
                String[] splitLine = line.split(",");
                final String category = splitLine[0].trim();
                final String item = splitLine[1].trim();
                final String filename = splitLine[2].trim();
                List<String[]> catArray = presetMapSubmenuToItem.get(category);
                if (catArray == null)
                {
                    catArray = new ArrayList<String[]>();
                    presetMapSubmenuToItem.put(category, catArray);
                }
                catArray.add(new String[] { item, ConfigFont.INTERNAL_FILE_PREFIX + PRESET_DIRECTORY + filename });
            }
        }
        catch (Exception e)
        {
            logger.error("Error loading preset index" + (errorLine == null ? "" : "\n" + errorLine), e);
        }

        return presetMapSubmenuToItem;
    }

    private static List<String> loadIndexFile(String indexFilename) throws IOException
    {
        InputStream is = AssetIndexLoader.class.getClassLoader().getResourceAsStream(indexFilename);
        BufferedReader reader = new BufferedReader(new InputStreamReader(is));
        List<String> lines = new ArrayList<String>();
        String line;
        while ((line = reader.readLine()) != null)
        {
            lines.add(line);
        }
        return lines;
    }

} public class ChatPanel extends JPanel implements MouseWheelListener
{
    private static final Logger logger = Logger.getLogger(ChatPanel.class);

    private static final long serialVersionUID = 1L;

    /**
     * The cache of messages to be displayed
     */
    private ConcurrentLinkedQueue<Message> messages;

    private MessageCensorPanel censor;

    /**
     * Contains the timed task and a reference to this chat to be used to progress rolling out new messages at the
     * appropriate speed
     */
    private MessageProgressor messageProgressor;

    /**
     * Contains the timed task and a reference to this chat to be used to refresh periodically in case any messages have
     * been expired
     */
    private MessageExpirer messageExpirer;

    /**
     * The sprite used to draw the border around the chat, to be displayed if the border scale is greater than zero
     */
    private Sprite border;

    /**
     * The number of lines for all the messages in the chat buffer. This is not the number of messages, but the number
     * of lines the messages will take up once drawn.
     */
    private int lineCount;

    /**
     * The number of lines that fit on the screen between the top border and bottom border.
     */
    private int onScreenLineCount;

    /**
     * Configuration for the font and the border
     */
    private ConfigFont fontConfig;

    /**
     * Configuration for the chat, meaning whether scrolling is enabled and whether and how to draw the chroma key
     * border
     */
    private ConfigChat chatConfig;

    /**
     * Configuration for which colors to use to draw the chat
     */
    private ConfigColor colorConfig;

    /**
     * Configuration for how to draw the messages, what parts of the messages to display, the rate to display new
     * messages, the format for the timestamps, and the queue size
     */
    private ConfigMessage messageConfig;

    /**
     * Configuration for whether to include emoji in the messages
     */
    private ConfigEmoji emojiConfig;

    /**
     * Configuration for how to censor messages
     */
    private ConfigCensor censorConfig;

    /**
     * The control panel for the debug options. They aren't saved, so a reference to the control panel itself is
     * sufficient
     */
    private ControlPanelDebug debugSettings;

    /**
     * The font used to draw the chat messages
     */
    private SpriteFont font;

    /**
     * This indicates whether the configuration has been loaded. Before this is true, no call to any methods that draw
     * should be called because they all rely on the configuration. Once it is set to true, it will remain true- it is
     * only on the initial setup that configuration might be null
     */
    private boolean loaded;

    /**
     * Manages emoji loading, caching, and access
     */
    private EmojiManager emojiManager;

    /**
     * Construct the ChatPanel, which contains the entire visualization of the chat
     * 
     * @param censor
     *            The message popup dialog from the Control Window to be updated when a message is posted so the censor
     *            list is current
     * @throws IOException
     */
    public ChatPanel() throws IOException
    {
        loaded = false;
        lineCount = Integer.MAX_VALUE;
        onScreenLineCount = 0;
        messages = new ConcurrentLinkedQueue<Message>();

        emojiManager = new EmojiManager();
        messageProgressor = new MessageProgressor(this);
        messageExpirer = new MessageExpirer(this);
    }

    /**
     * Get whether the configuration and message dialog have been loaded. Before this is true, no call to any methods
     * that draw should be called because they all rely on the configuration and the link to the message dialog to be
     * updated so censorship rules can be assessed. Once it returns true, it will remain true- it is only on the initial
     * setup that configuration and the message dialog might be null.
     * 
     * @return loaded
     */
    public boolean isLoaded()
    {
        return loaded && censor != null;
    }

    /**
     * Set the configuration references from the properties object. This method instantiates the font and border. Once
     * this method is complete, loaded is set to true.
     * 
     * @param fProps
     *            The properties from which to get the configuration references
     * @throws IOException
     *             If there are any issues loading the files specified for the font and border
     */
    public void setConfig(FontificatorProperties fProps) throws IOException
    {
        logger.trace("Setting chat panel config via fontificator properties object");
        this.fontConfig = fProps.getFontConfig();
        this.chatConfig = fProps.getChatConfig();
        this.colorConfig = fProps.getColorConfig();
        this.messageConfig = fProps.getMessageConfig();
        this.emojiConfig = fProps.getEmojiConfig();
        this.censorConfig = fProps.getCensorConfig();

        font = new SpriteFont(fontConfig);
        reloadFontFromConfig();
        // This initializes the border
        reloadBorderFromConfig();

        // This indicates that the chat panel is ready to be drawn
        loaded = true;
    }

    /**
     * Set the Control Panel for debugging on this chat panel
     * 
     * @param debugSettings
     *            The control panel for debug settings
     */
    public void setDebugSettings(ControlPanelDebug debugSettings)
    {
        this.debugSettings = debugSettings;
    }

    public void setMessageCensor(MessageCensorPanel censor)
    {
        this.censor = censor;
    }

    @Override
    public void paint(Graphics g)
    {
        if (!isLoaded())
        {
            return;
        }

        Graphics2D g2d = (Graphics2D) g;

        if (chatConfig.isAntiAlias())
        {
            g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
        }

        // Fits in the line height.
        boolean stillFits = true;
        int fontSize = 0;
        while (stillFits)
        {
            fontSize++;
            g2d.setFont(new Font(g2d.getFont().getName(), Font.PLAIN, fontSize));
            stillFits = (font.getFontHeight() - fontConfig.getBaselineOffset()) * fontConfig.getFontScale() > g2d.getFontMetrics().getStringBounds("A", 0, 1, g2d).getHeight();
        }

        logger.trace("Calulated font size: " + fontSize);

        List<Message> drawMessages = new ArrayList<Message>();

        // Make a copy of the actual cache that only includes the messages that are completely drawn and possibly the
        // one message currently being drawn
        long drawTime = System.currentTimeMillis();
        for (Message msg : messages)
        {
            final boolean censored = censorConfig.isCensorshipEnabled() && msg.isCensored();
            final boolean expired = messageConfig.isMessageExpirable() && msg.getAge(drawTime) > messageConfig.getExpirationTime();
            if (!censored && !expired)
            {
                drawMessages.add(msg);
            }
            if (!censored && !expired && !msg.isCompletelyDrawn())
            {
                // No need to check any further messages because this is the one currently being rolled out
                break;
            }
        }

        // Draws the background color and the chroma key border
        if (messageConfig.isHideEmptyBackground() && drawMessages.isEmpty())
        {
            // If the messages are empty and the background should be hidden, draw the chroma color regardless of whether it's enabled
            g2d.setColor(colorConfig.getChromaColor());
            g2d.fillRect(0, 0, getWidth(), getHeight());
        }
        else
        {
            drawBackgroundAndChroma(g2d);
        }

        // This offset represents how far inward in the x and y directions the messages should be drawn
        Point offset = new Point();

        // If border scale is zero, just skip this. The drawBorder method won't draw a zero scale border, but if these
        // calculations are attempted with a zero scale it will throw a divide by zero exception
        // Also check if no messages are visible whether the border should be hidden
        if (fontConfig.getBorderScale() > 0.0f && !(messageConfig.isHideEmptyBorder() && drawMessages.isEmpty()))
        {
            final int gridWidth = getWidth() / border.getSpriteDrawWidth(fontConfig.getBorderScale());
            final int gridHeight = getHeight() / border.getSpriteDrawHeight(fontConfig.getBorderScale());

            final int leftOffset = (getWidth() - gridWidth * border.getSpriteDrawWidth(fontConfig.getBorderScale())) / 2;
            final int topOffset = (getHeight() - gridHeight * border.getSpriteDrawHeight(fontConfig.getBorderScale())) / 2;

            offset = new Point(leftOffset, topOffset);

            drawBorder(g2d, gridWidth, gridHeight, offset, colorConfig.getBorderColor(), debugSettings.isDrawBorderGrid(), debugSettings.getBorderGridColor());
        }

        drawChat(g2d, drawMessages, offset, debugSettings.isDrawTextGrid(), debugSettings.getTextGridColor());
    }

    /**
     * Draws a test grid for debugging purposes
     * 
     * @param g
     * @param one
     * @param two
     * @param x
     * @param y
     * @param width
     * @param height
     * @param squareSize
     */
    protected void drawGrid(Graphics g, Color one, Color two, int x, int y, int width, int height, int squareSize)
    {
        for (int r = 0; r < height / squareSize + 1; r++)
        {
            for (int c = 0; c < width / squareSize + 1; c++)
            {
                boolean colorOne = r % 2 == 1 && c % 2 == 0 || r % 2 == 0 && c % 2 == 1;
                g.setColor(colorOne ? one : two);
                g.fillRect(x + c * squareSize, y + r * squareSize, squareSize, squareSize);
            }
        }
    }

    /**
     * Draw the background and the chroma key border
     * 
     * @param g2d
     */
    private void drawBackgroundAndChroma(Graphics2D g2d)
    {
        if (chatConfig.isChromaEnabled())
        {
            g2d.setColor(chatConfig.isChromaInvert() ? colorConfig.getBgColor() : colorConfig.getChromaColor());
            g2d.fillRect(0, 0, getWidth(), getHeight());
            g2d.setColor(chatConfig.isChromaInvert() ? colorConfig.getChromaColor() : colorConfig.getBgColor());
            Rectangle border = chatConfig.getChromaBorder();
            g2d.fillRoundRect(Math.min(getWidth(), border.x), Math.min(getHeight(), border.y), Math.max(0, getWidth() - border.width - border.x), Math.max(0, getHeight() - border.height - border.y), chatConfig.getChromaCornerRadius(), chatConfig.getChromaCornerRadius());
        }
        else
        {
            // Just draw the background
            g2d.setColor(colorConfig.getBgColor());
            g2d.fillRect(0, 0, getWidth(), getHeight());
        }
    }

    /**
     * Draw the words in the messages in the chat
     * 
     * @param g2d
     * @param messages
     * @param offset
     */
    private void drawChat(Graphics2D g2d, List<Message> drawMessages, Point offset, boolean debug, Color debugColor)
    {
        final int lineWrapLength = (border == null || fontConfig.getBorderScale() <= 0.0f ? getWidth() : border.getSpriteDrawWidth(fontConfig.getBorderScale()) * (getWidth() / border.getSpriteDrawWidth(fontConfig.getBorderScale()) - 2)) - fontConfig.getBorderInsetX() * 2;
        final int leftEdge = offset.x + (border == null || fontConfig.getBorderScale() <= 0.0f ? 0 : border.getSpriteDrawWidth(fontConfig.getBorderScale())) + fontConfig.getBorderInsetX();

        // totalHeight is the height of all the messages
        int totalHeight = 0;
        for (int i = 0; i < drawMessages.size(); i++)
        {
            Message msg = drawMessages.get(i);
            final boolean lastMessage = i >= drawMessages.size() - 1;
            Dimension dim = font.getMessageDimensions(msg, g2d.getFontMetrics(), messageConfig, emojiConfig, emojiManager, lineWrapLength, lastMessage);
            totalHeight += dim.getHeight();
        }

        // Used for scrolling
        int lineHeight = font.getLineHeightScaled();
        if (lineHeight == 0)
        {
            lineHeight = 1;
        }
        lineCount = lineHeight == 0 ? 0 : totalHeight / lineHeight;

        // borderEdgeThickness is the y-inset on the top plus the height of the top part of the border
        final int borderEdgeThickness = offset.y + (border == null || fontConfig.getBorderScale() < ConfigFont.FONT_BORDER_SCALE_GRANULARITY ? 0 : border.getSpriteDrawHeight(fontConfig.getBorderScale())) + fontConfig.getBorderInsetY();

        final int drawableVerticalRange = getHeight() - borderEdgeThickness * 2;

        // Used for scrolling when chat scrolls normally and starts from the top, or when chat scrolls reverse and starts from the bottom
        onScreenLineCount = drawableVerticalRange / lineHeight;

        // y is where the drawing begins
        int y;
        if (chatConfig.isChatFromBottom())
        {
            if (chatConfig.isReverseScrolling())
            {
                if (totalHeight > drawableVerticalRange)
                {
                    y = borderEdgeThickness;
                }
                else
                {
                    y = getHeight() - totalHeight - borderEdgeThickness;
                }
            }
            else
            {
                y = getHeight() - totalHeight - borderEdgeThickness;
            }
        }
        // else chat from top
        else
        {
            if (chatConfig.isReverseScrolling())
            {
                y = borderEdgeThickness;
            }
            else
            {
                if (totalHeight > drawableVerticalRange)
                {
                    // Not all the messages fit in the given space range, so start drawing up out of bounds at a negative y. This uses just the top borderEdgeThickness's height, not both top and bottom
                    y = (getHeight() - borderEdgeThickness) - totalHeight;
                }
                // If the total height of all the messages is less than or equal to the total height
                else
                {
                    // Just set the y to start drawing to the borderEdgeThickness because it should be fixed to the top when there's enough room for everything
                    y = borderEdgeThickness;
                }
            }
        }

        final int botLimit;
        if (chatConfig.isReverseScrolling() && totalHeight > drawableVerticalRange)
        {
            botLimit = getHeight() - borderEdgeThickness - font.getLineHeightScaled();
        }
        else
        {
            botLimit = getHeight() - borderEdgeThickness;
        }

        // Draw each message in the drawMessages copy of the cache
        for (int i = 0; i < drawMessages.size(); i++)
        {
            int msgIndex = chatConfig.isReverseScrolling() ? drawMessages.size() - i - 1 : i;
            Message msg = drawMessages.get(msgIndex);
            Color col = getUsernameColor(colorConfig, msg);
            final boolean lastMessage = i >= drawMessages.size() - 1;
            // The call to drawMessage in SpriteFont will determine whether to draw each character based on whether it is located at a position appropriate to be drawn on
            Dimension dim = font.drawMessage(g2d, g2d.getFontMetrics(), msg, col, colorConfig, messageConfig, emojiConfig, emojiManager, leftEdge, y, borderEdgeThickness, botLimit, lineWrapLength, debug, debugColor, this, lastMessage);
            y += dim.getHeight();
        }
    }

    private static Color getUsernameColor(ConfigColor colorConfig, Message msg)
    {
        Color col;
        if (msg.isJoinType())
        {
            col = colorConfig.getHighlight();
        }
        else if (colorConfig.isUseTwitchColors() && msg.getPrivmsg().getColor() != null)
        {
            col = msg.getPrivmsg().getColor();
        }
        else
        {
            col = colorConfig.getPalette().isEmpty() ? colorConfig.getHighlight() : colorConfig.getPalette().get(Math.abs(msg.getUsername().toLowerCase().hashCode()) % colorConfig.getPalette().size());
        }
        return col;
    }

    /**
     * Draw the border
     * 
     * @param g2d
     * @param gridWidth
     * @param gridHeight
     * @param offset
     * @param color
     */
    private void drawBorder(Graphics2D g2d, int gridWidth, int gridHeight, Point offset, Color color, boolean debug, Color debugColor)
    {
        final float scale = fontConfig.getBorderScale();

        if (scale <= 0.0f)
        {
            return;
        }

        if (debug)
        {
            g2d.setColor(debugColor);
        }

        for (int r = 0; r < gridHeight; r++)
        {
            for (int c = 0; c < gridWidth; c++)
            {
                int pixelX = c * border.getSpriteDrawWidth(scale) + offset.x;
                int pixelY = r * border.getSpriteDrawHeight(scale) + offset.y;

                if (r == 0) // Top row
                {
                    if (c == 0) // Top left
                    {
                        border.draw(g2d, pixelX, pixelY, 0, scale, color);
                    }
                    else if (c == gridWidth - 1) // Top right
                    {
                        border.draw(g2d, pixelX, pixelY, 2, scale, color);
                    }
                    else
                    // Top middle
                    {
                        border.draw(g2d, pixelX, pixelY, 1, scale, color);
                    }
                }
                else if (r == gridHeight - 1) // Bottom row
                {
                    if (c == 0) // Bottom left
                    {
                        border.draw(g2d, pixelX, pixelY, 6, scale, color);
                    }
                    else if (c == gridWidth - 1) // Bottom right
                    {
                        border.draw(g2d, pixelX, pixelY, 8, scale, color);
                    }
                    else
                    // Bottom middle
                    {
                        border.draw(g2d, pixelX, pixelY, 7, scale, color);
                    }
                }
                else
                // Middle
                {
                    if (c == 0) // Middle left
                    {
                        border.draw(g2d, pixelX, pixelY, 3, scale, color);
                    }
                    else if (c == gridWidth - 1) // Middle right
                    {
                        border.draw(g2d, pixelX, pixelY, 5, scale, color);
                    }
                    else
                    // Middle middle
                    {
                        border.draw(g2d, pixelX, pixelY, 4, scale, color);
                    }
                }
                if (debug)
                {
                    g2d.drawRect(pixelX, pixelY, (int) (border.getSpriteWidth() * scale), (int) (border.getSpriteHeight() * scale));
                }
            }
        }
    }

    /**
     * Add a message to the cache, and call method to process any censorship
     * 
     * @param addition
     */
    synchronized public void addMessage(Message addition)
    {
        if (addition.isJoinType() && !messageConfig.showJoinMessages())
        {
            return;
        }

        censor.checkCensor(addition);

        // Note that for a moment here, the size of messages can exceed the specified queueSize in the message config,
        // so if another thread is accessing this, be sure to take that into consideration
        messages.add(addition);
        int remCount = Math.max(0, messages.size() - messageConfig.getQueueSize());

        Iterator<Message> iter = messages.iterator();
        while (iter.hasNext() && remCount > 0)
        {
            iter.next();
            iter.remove();
            remCount--;
        }

        initMessageRollout();
        if (censor.isVisible())
        {
            censor.updateManualTable();
        }

        repaint();
    }

    /**
     * Delete all messages from the queue to clear the chat
     */
    synchronized public void clearChat()
    {
        messages.clear();
        repaint();
    }

    /**
     * Reset the scroll offset to zero
     */
    public void resetScrollOffset()
    {
        if (isLoaded())
        {
            font.setLineScrollOffset(0);
        }
    }

    /**
     * Refresh the scroll offset in case of resize (only needed when chat starts at top)
     * 
     * @param positiveDirection
     *            Whether the direction is up or down
     * @param lines
     *            how many lines to scroll
     */
    public void incrementScrollOffset(boolean positiveDirection, int lines)
    {
        if (isLoaded())
        {
            final int dir = positiveDirection ? lines : -lines;
            if (chatConfig.isChatFromBottom())
            {
                font.incrementLineScrollOffset(dir, 0, lineCount);
            }
            else
            {
                final boolean screenIsOverflowing = lineCount >= onScreenLineCount;
                if (screenIsOverflowing)
                {
                    font.incrementLineScrollOffset(dir, -onScreenLineCount + 1, lineCount - onScreenLineCount + 1);
                }
                else
                {
                    font.incrementLineScrollOffset(dir, lineCount == 0 ? 0 : -lineCount + 1, 1);
                }
            }
            repaint();
        }
    }

    /**
     * This is added to the window above
     */
    @Override
    public void mouseWheelMoved(MouseWheelEvent e)
    {
        if (isLoaded() && chatConfig.isScrollable())
        {
            incrementScrollOffset(e.getWheelRotation() < 0, 1);
        }
    }

    /**
     * Get the message cache. This is used by the timer task that resides in the MessageProgressor object to increment
     * the drawing of the messages
     * 
     * @return messages
     */
    synchronized public Message[] getMessages()
    {
        return messages.toArray(new Message[messages.size()]);
    }

    /**
     * Get the actual queue of messages
     * 
     * @return messages
     */
    public ConcurrentLinkedQueue<Message> getMessageQueue()
    {
        return messages;
    }

    /**
     * Updates the font when there are changes to the configuration
     * 
     * @throws IOException
     *             If there is a problem using a file
     */
    public void reloadFontFromConfig() throws IOException
    {
        font.updateForConfigChange();
    }

    /**
     * Updates the border when there are changes to the configuration
     * 
     * @throws IOException
     */
    public void reloadBorderFromConfig() throws IOException
    {
        try
        {
            border = new Sprite(fontConfig.getBorderFilename(), 3, 3);
        }
        catch (Exception e)
        {
            final String errorMessage = "Unable to load border sprite " + (fontConfig == null ? "for null font configuration" : "for border filename " + fontConfig.getBorderFilename());
            logger.error(errorMessage, e);
            border = new Sprite();
        }
    }

    /**
     * Used for getting the message configuration for formatting the message and timing the message progression
     * 
     * @return message config
     */
    public ConfigMessage getMessageConfig()
    {
        return messageConfig;
    }

    /**
     * Used for getting the emoji configuration for formatting the message
     * 
     * @return emoji config
     */
    public ConfigEmoji getEmojiConfig()
    {
        return emojiConfig;
    }

    /**
     * Get the message progressor
     * 
     * @return messageProgressor
     */
    public MessageProgressor getMessageProgressor()
    {
        return messageProgressor;
    }

    public MessageExpirer getMessageExpirer()
    {
        return messageExpirer;
    }

    public void banUser(String bannedUser)
    {
        censor.addBan(bannedUser);
        repaint();
    }

    public void unbanUser(String bannedUser)
    {
        censor.removeBan(bannedUser);
        repaint();
    }

    public EmojiManager getEmojiManager()
    {
        return emojiManager;
    }

    public void initExpirationTimer()
    {
        if (messageConfig.isMessageExpirable())
        {
            messageExpirer.startClock();
        }
    }

    /**
     * Attempt to restart the message rollout, called whenever some messages might be reintroduced to the drawMessage
     * after the message rollout is completed, by being uncensored for example. This call relies on the fact that the
     * messageProgression will halt again if all the messages are complete already.
     */
    public void initMessageRollout()
    {
        messageProgressor.startClock(messageConfig.getMessageDelay());
    }

    public boolean isCensorshipEnabled()
    {
        return censorConfig.isCensorshipEnabled();
    }

    /**
     * Used to purge messages from chat whenever a user is timed-out or banned by a Twitch moderator
     * 
     * @param username
     * @param reason
     */
    public void purgeMessagesForUser(final String username, final String reason)
    {
        censor.purgeMessagesForUser(username, reason);
    }

    public String getFontGameName()
    {
        return ControlPanelFont.getFontGameName(fontConfig.getFontFilename());
    }

    public String getBorderGameName()
    {
        return ControlPanelFont.getBorderGameName(fontConfig.getBorderFilename());
    }
} 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 MessageDialog extends JDialog
{
    private static final long serialVersionUID = 1L;

    private MessagePostPanel postPanel;

    private MessageCensorPanel censorPanel;

    public MessageDialog(FontificatorProperties fProps, ChatWindow chatWindow, ControlWindow ctrlWindow, LogBox logBox)
    {
        super(chatWindow);
        ChatWindow.setupHideOnEscape(this);
        setDefaultCloseOperation(JDialog.HIDE_ON_CLOSE);
        build(fProps, chatWindow, ctrlWindow, logBox);
    }

    private void build(FontificatorProperties fProps, ChatWindow chatWindow, ControlWindow ctrlWindow, LogBox logBox)
    {
        setTitle("Message Management");

        this.postPanel = new MessagePostPanel(ctrlWindow);
        this.censorPanel = new MessageCensorPanel(fProps, chatWindow, ctrlWindow, logBox);

        setLayout(new GridBagLayout());
        GridBagConstraints gbc = ControlPanelBase.getGbc();
        gbc.weightx = 1.0;
        gbc.anchor = GridBagConstraints.NORTH;
        gbc.fill = GridBagConstraints.BOTH;
        gbc.weighty = 1.0;
        add(censorPanel, gbc);
        gbc.gridy++;
        gbc.weighty = 0.0;
        gbc.fill = GridBagConstraints.HORIZONTAL;
        add(postPanel, gbc);
        gbc.gridy++;

        setSize(700, 500);
        setMinimumSize(getSize());
        setSize(700, 700);
    }

    public void showDialog()
    {
        setVisible(true);
    }

    public MessageCensorPanel getCensorPanel()
    {
        return censorPanel;
    }

} public class ControlPanelDebug extends ControlPanelBase
{
    private static final long serialVersionUID = 1L;

    /**
     * Whether debugging is activated, which should correspond to when this panel is displayed
     */
    private boolean debugging;

    private ControlWindow ctrlWindow;

    private DebugAppender debugAppender;

    private JButton postTestMessage;

    private JToggleButton postMessagesButton;

    private LabeledSlider postRateSlider;

    private JCheckBox drawTextGridBox;

    private ColorButton textGridColorButton;

    private JCheckBox drawBorderGridBox;

    private ColorButton borderGridColorButton;

    private Random rnd;

    private Timer postClock;

    // @formatter:off
    private String[] TEST_USERNAMES = new String[] { "Mario", "Luigi", "Peach", "Toad", "Bowser", "Shyguy", "Yoshi", "Birdo", "Goomba", "Koopa", 
                                                     "Link", "Zelda", "Impa", "Shiek", "Navi", "Tingle", "Error", "Bagu", "Agahnim", "Ganon", "Ganondorf", 
                                                     "Ryu", "Chun-Li", "Ken", "Blanka", "Guile", "Sagat", "Vega", "Zangief", "Balrog", "Dhalsim", 
                                                     "Samus", "Ridley", "Kraid", "Simon", "Alucard", "Kirby", "Chell", "GLaDOS", "Guycott", "Pacman", "Guybrush", 
                                                     "Sonic", "Tails", "Knuckles", "Robotnik", "Eggman", 
                                                     "Bulbasaur", "Squirtle", "Charmander", "Pikachu", "Zubat", "Caterpie", "Jigglypuff", "Psyduck", "Meowth", "Mewtwo", "Snorlax", "Magikarp", 
                                                     "Ness", "Paula", "Jeff", "Poo", "Lucas", "Claus", "Flint", "Hinawa", 
                                                     "Chrono", "Marle", "Lucca", "Robo", "Magus", "Frog", "Ayla", "Gato", "Lavos", "Schala", "Dalton", "Serge", "Lynx", "Kid", "Harle", 
                                                     "Alena", "Talloon", "Ragnar", "Brey", "Cristo", "Maya", "Nara", "Maribel", "Kiefer", "Gabo", "Melvin", "Aira", 
                                                     "Hargon", "Malroth", "Kandar", "Baramos", "Zoma", "Estark", "Aamon", "Balzack", "Psaro", "Necrosaro",  
                                                     "Terra", "Locke", "Celes", "Edgar", "Sabin", "Kefka", "Cloud", "Tifa", "Aerith", "Barret", "Sephiroth" };

    private String[] TEST_MESSAGES = new String[] { "You presumptuous little twit. I'll have you for lunch!", 
                                                    "Thank you Mario! But our princess is in another castle!", 
                                                    "It's dangerous to go alone. Take this.", 
                                                    "It's a secret to everybody.", 
                                                    "I feel asleep!!", 
                                                    "Now it is the beginning of a fantastic story!", 
                                                    "Let us make a journey to the cave of monsters!", 
                                                    "A winner is you!", 
                                                    "Let's play money making game.", 
                                                    "A Slime draws near! Command?", 
                                                    "I am Error.", 
                                                    "Hey, listen!", 
                                                    "Bagu is my name. Show my note to river man.", 
                                                    "What a horrible night to have a curse.", 
                                                    "The morning sun has vanquished the horrible night.", 
                                                    "You now prossess Dracula's Rib", 
                                                    "Take my daughter, please!!", 
                                                    "Get a Silk Bag from the Graveyard Duck to live longer.", 
                                                    "All your base are belong to us.", 
                                                    "The morning sun has vanquished the horrible night.", 
                                                    "Get a silk bag from the graveyard duck to live longer.", 
                                                    "Someone set us up the bomb.", 
                                                    "Are you a bad enough dude to rescue the president?", 
                                                    "That's the second biggest monkey head I've ever seen!", 
                                                    "Now you're thinking with portals.", 
                                                    "Would you kindly?", 
                                                    "The cake is a lie.", 
                                                    "We've both said a lot of things that you're going to regret.", 
                                                    "It's like my Rattata is in the top percentage of rattatas.", 
                                                    "You have died of dysentery.", 
                                                    "Curse you Link!", 
                                                    "Time passes, people move. Like a river's flow, it never ends.", 
                                                    "I... I shall consume. Consume... consume everything...", 
                                                    "You've met with a terrible fate, haven't you?", 
                                                    "I really am the richest duck in the world.", 
                                                    "The fate of the forest nay the world depends upon thee.", 
                                                    "Well excuuuuuse me, princess!", 
                                                    "Bombs! Bombs are the answer!", 
                                                    "The name of the Archfiend Baramos is yet unknown in the world of men.", 
                                                    "But Thou Must!", 
                                                    "'Tis about as useful as a chocolate teapot.", 
                                                    "Thou wilt regret thy coming indeed! You shall be dead for good, for I wilt surely feast on thine innards!", 
                                                    "What is a man? A miserable little pile of secrets! But enough talk! Have at you!", 
                                                    "I'll not ask you to return to our side, but I demand you cease your attack.", 
                                                    "Perhaps you can save his haunted soul.", 
                                                    "Machines aren't capable of evil. Humans make them that way.", 
                                                    "Lower thine guard, and thou'rt allowing the enemy in.", 
                                                    "Old man breathe, but dead on inside!", 
                                                    "Do you wish to fight me?", 
                                                    "Beat me up and earn fifteen silver points!", 
                                                    "This is not enough golds.", 
                                                    "Your fists of evil are about to meet my steel wall of niceness.", 
                                                    "Thanks a million!", 
                                                    "You have completed a great game.", 
                                                    "You spoony bard!", 
                                                    "Welcome to die!", 
                                                    "You must defeat my Dragon Punch to stand a chance!", 
                                                    "You did quite well, but you need more training to defeat me!", 
                                                    "It's natural for a sumo wrestler to become the world's strongest!", 
                                                    "Can't you do better than that?", 
                                                    "Seeing you in action is a joke!", 
                                                    "Now you realize the powers I possess!", 
                                                    "Are you man enough to fight with me?", 
                                                    "Go home and be a family man!", 
                                                    "Get up!! It's too early for you to be defeated!", 
                                                    "Attack me if you dare, I will crush you!", 
                                                    "There is no chance for you to beat me! Challenge someone else!", 
                                                    "I'm the strongest woman in the world!", 
                                                    "My strength is much greater than yours!", 
                                                    "Next time we meet, I'm gonna break your arms!", 
                                                    "Now you've realized the inner mysteries of yoga!", 
                                                    "I will meditate and then destroy you!!", 
                                                    "Get up you wimp!", 
                                                    "Hey, What happened? I'm not through with you yet!", 
                                                    "Handsome fighters never lose a battle!", 
                                                    "Thank you for a gorgeous time!", 
                                                    "You are not a warrior, you're a beginner!", 
                                                    "I am not satisfied until I have the world's strongest title again!", 
                                                    "Get lost, you can't compare with my powers!", 
                                                    "Anyone who opposes me will be destroyed!"
                                                   };
    // @formatter:on

    private void postRandomMessage()
    {
        final String username = TEST_USERNAMES[rnd.nextInt(TEST_USERNAMES.length)];
        final String message = TEST_MESSAGES[rnd.nextInt(TEST_MESSAGES.length)];
        ctrlWindow.addManualMessage(username, message);
    }

    /**
     * Construct a debug control panel
     * 
     * @param fProps
     * @param chatWindow
     * @param ctrlWindow
     */
    public ControlPanelDebug(FontificatorProperties fProps, ChatWindow chatWindow, ControlWindow ctrlWindow)
    {
        super("Debug", fProps, chatWindow, new LogBox());
        this.debugging = false;
        this.ctrlWindow = ctrlWindow;
        rnd = new Random();
        debugAppender = new DebugAppender(logBox);
    }

    public boolean isDrawTextGrid()
    {
        return isDebugging() && drawTextGridBox.isSelected();
    }

    public boolean isDrawBorderGrid()
    {
        return isDebugging() && drawBorderGridBox.isSelected();
    }

    public Color getTextGridColor()
    {
        return textGridColorButton.getColor();
    }

    public Color getBorderGridColor()
    {
        return borderGridColorButton.getColor();
    }

    @Override
    protected void build()
    {
        postTestMessage = new JButton("Post Debug Message");

        postTestMessage.addActionListener(new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                ctrlWindow.addManualMessage("Test", SpriteFont.NORMAL_ASCII_KEY);
            }
        });

        postMessagesButton = new JToggleButton("Post Messages");
        postRateSlider = new LabeledSlider("Post Rate ", " / min", 1, 120, 30, 3);

        postClock = new Timer(60000 / postRateSlider.getValue(), new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                postRandomMessage();
            }
        });

        postRateSlider.addChangeListener(new ChangeListener()
        {
            @Override
            public void stateChanged(ChangeEvent e)
            {
                if (!((JSlider) e.getSource()).getValueIsAdjusting())
                {
                    postClock.setDelay(60000 / postRateSlider.getValue());
                    if (postClock.isRunning())
                    {
                        postRandomMessage();
                        postClock.restart();
                    }
                }
            }
        });

        postMessagesButton.addActionListener(new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                postRandomMessage();
                if (postMessagesButton.isSelected())
                {
                    postClock.start();
                }
                else
                {
                    postClock.stop();
                }
            }
        });

        drawTextGridBox = new JCheckBox("Draw Text Grid");
        textGridColorButton = new ColorButton("Text Grid Color", new Color(0x99FF88), "Color of the border grid", this);
        drawBorderGridBox = new JCheckBox("Draw Border Grid");
        borderGridColorButton = new ColorButton("Border Grid Color", new Color(0x9988FF), "Color of the border grid", this);

        ActionListener refreshListener = new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                if (chat != null)
                {
                    chat.repaint();
                }
            }
        };

        drawTextGridBox.addActionListener(refreshListener);
        drawBorderGridBox.addActionListener(refreshListener);

        JPanel topPanel = new JPanel(new GridBagLayout());
        gbc.gridx = 0;
        gbc.gridy = 0;
        gbc.weightx = 0.0;
        gbc.weighty = 0.0;
        gbc.fill = GridBagConstraints.NONE;
        topPanel.add(postTestMessage, gbc);
        gbc.gridx++;
        topPanel.add(postMessagesButton, gbc);
        gbc.gridx++;
        gbc.weightx = 1.0;
        gbc.fill = GridBagConstraints.HORIZONTAL;
        topPanel.add(postRateSlider, gbc);

        gbc.gridwidth = 4;
        gbc.gridx = 0;
        gbc.gridy = 0;
        add(topPanel, gbc);
        gbc.gridy++;

        gbc.gridwidth = 1;

        gbc.weightx = 0.5;
        gbc.fill = GridBagConstraints.HORIZONTAL;
        add(drawTextGridBox, gbc);
        gbc.gridx++;
        add(textGridColorButton, gbc);
        gbc.gridx++;
        add(drawBorderGridBox, gbc);
        gbc.gridx++;
        add(borderGridColorButton, gbc);
        gbc.gridy++;

        gbc.gridx = 0;
        gbc.gridwidth = 4;
        gbc.weightx = 1.0;

        gbc.weighty = 0.1;
        CollagePanel collagePanel = new CollagePanel(chat);
        add(collagePanel, gbc);
        gbc.gridy++;

        ExamplePanel examplePanel = new ExamplePanel(this);
        add(examplePanel, gbc);
        gbc.gridy++;

        gbc.weighty = 0.9;
        gbc.anchor = GridBagConstraints.SOUTH;
        gbc.fill = GridBagConstraints.BOTH;

        gbc.gridwidth = 4;

        add(logBox, gbc);
    }

    @Override
    protected void fillInputFromProperties(FontificatorProperties fProps)
    {
        fillInputFromConfig();
    }

    @Override
    protected void fillInputFromConfig()
    {
    }

    @Override
    protected LoadConfigReport validateInput()
    {
        return new LoadConfigReport();
    }

    @Override
    public void fillConfigFromInput() throws Exception
    {
    }

    /**
     * Enable or disable debugging
     * 
     * @param debugging
     */
    public void setDebugging(boolean debugging)
    {
        this.debugging = debugging;
        if (debugging)
        {
            Thread.setDefaultUncaughtExceptionHandler(debugAppender);
            BasicConfigurator.configure(debugAppender);
        }
        else
        {
            // Turn off everything before disabling the debug tab
            postClock.stop();
            postMessagesButton.setSelected(false);
            drawTextGridBox.setSelected(false);
            drawBorderGridBox.setSelected(false);
            chat.repaint();
            Thread.setDefaultUncaughtExceptionHandler(null);
            Logger.getRootLogger().removeAppender(debugAppender);
        }
    }

    public boolean isDebugging()
    {
        return debugging;
    }
} public class ControlPanelFont extends ControlPanelBase
{
    private static final Logger logger = Logger.getLogger(ControlPanelFont.class);

    private static final long serialVersionUID = 1L;

    /**
     * Text of the selection option in the font and border preset dropdown menus to prompt the user to specify her own
     * file
     */
    public static final DropdownLabel CUSTOM_KEY = new DropdownLabel(null, "Custom...");

    private static final Color SCALE_EVEN = Color.BLACK;

    private static final Color SCALE_UNEVEN = new Color(0x661033);

    private static final Map<DropdownLabel, DropdownFont> PRESET_FONT_FILE_MAP = AssetIndexLoader.loadFonts();

    /**
     * A reverse lookup through the map to get the actual name of a game for a font
     * 
     * @param font
     *            filename
     * @return game name
     */
    public static String getFontGameName(String filename)
    {
        for (Entry<DropdownLabel, DropdownFont> e : PRESET_FONT_FILE_MAP.entrySet())
        {
            if (CUSTOM_KEY.equals(e.getKey()))
            {
                continue;
            }

            final String fontFilename = e.getValue().getFontFilename();

            if (fontFilename.equals(filename))
            {
                return e.getKey().getLabel();
            }
        }
        return "Unknown";
    }

    public static List<DropdownFont> getAllFonts()
    {
        List<DropdownFont> allFonts = new ArrayList<DropdownFont>();
        for (DropdownLabel ddfKey : PRESET_FONT_FILE_MAP.keySet())
        {
            if (CUSTOM_KEY.equals(ddfKey))
            {
                continue;
            }
            allFonts.add(PRESET_FONT_FILE_MAP.get(ddfKey));
        }
        return allFonts;
    }

    private static Map<DropdownLabel, DropdownBorder> PRESET_BORDER_FILE_MAP = AssetIndexLoader.loadBorders();

    /**
     * A reverse lookup through the map to get the actual name of a game for a border
     * 
     * @param border
     *            filename
     * @return game name
     */
    public static String getBorderGameName(String filename)
    {
        for (Entry<DropdownLabel, DropdownBorder> e : PRESET_BORDER_FILE_MAP.entrySet())
        {
            if (CUSTOM_KEY.equals(e.getKey()))
            {
                continue;
            }

            final String borderFilename = e.getValue().getBorderFilename();

            if (borderFilename.equals(filename))
            {
                return e.getKey().getLabel();
            }
        }
        return "Unknown";
    }

    private LabeledInput fontFilenameInput;

    private ComboMenuBar fontPresetDropdown;

    private JCheckBox fontTypeCheckbox;

    private LabeledInput borderFilenameInput;

    private ComboMenuBar borderPresetDropdown;

    private LabeledInput gridWidthInput;

    private LabeledInput gridHeightInput;

    private JFileChooser fontPngPicker;

    private JFileChooser borderPngPicker;

    private LabeledSlider fontScaleSlider;

    private LabeledSlider borderScaleSlider;

    private LabeledSlider borderInsetXSlider;

    private LabeledSlider borderInsetYSlider;

    private LabeledInput characterKeyInput;

    private LabeledSlider spaceWidthSlider;

    private LabeledSlider baselineOffsetSlider;

    private LabeledSlider lineSpacingSlider;

    private LabeledSlider charSpacingSlider;

    private LabeledSlider messageSpacingSlider;

    private JButton unknownCharPopupButton;

    private CharacterPicker charPicker;

    private JLabel unknownCharLabel;

    private JCheckBox extendedCharBox;

    private ConfigFont config;

    private ChangeListener sliderListener;

    private ActionListener fontTypeListener;

    private ControlPanelColor colorPanel;

    /**
     * Construct a font control panel
     * 
     * @param fProps
     * @param chatWindow
     * @param logBox
     * @param colorPanel
     *            Used to set the border color tint when the border is changed
     */
    public ControlPanelFont(FontificatorProperties fProps, ChatWindow chatWindow, LogBox logBox, ControlPanelColor colorPanel)
    {
        super("Font/Border", fProps, chatWindow, logBox);

        this.colorPanel = colorPanel;

        fontTypeCheckbox.addActionListener(fontTypeListener);

        fontPngPicker = new JFileChooser();
        borderPngPicker = new JFileChooser();
        FileFilter pngFilter = new FileFilter()
        {
            @Override
            public boolean accept(File f)
            {
                return f.isDirectory() || (f.getName().toLowerCase().endsWith(".png"));
            }

            @Override
            public String getDescription()
            {
                return "PNG Image (*.png)";
            }
        };
        fontPngPicker.setFileFilter(pngFilter);
        borderPngPicker.setFileFilter(pngFilter);
    }

    @Override
    protected void build()
    {
        sliderListener = new ChangeListener()
        {
            @Override
            public void stateChanged(ChangeEvent e)
            {
                JSlider source = (JSlider) e.getSource();
                if (fontScaleSlider.getSlider().equals(source))
                {
                    config.setFontScale(fontScaleSlider.getScaledValue());
                    fontScaleSlider.setValueTextColor(((int) config.getFontScale() == config.getFontScale()) ? SCALE_EVEN : SCALE_UNEVEN);
                }
                else if (borderScaleSlider.getSlider().equals(source))
                {
                    config.setBorderScale(borderScaleSlider.getScaledValue());
                    borderScaleSlider.setValueTextColor(((int) config.getBorderScale() == config.getBorderScale()) ? SCALE_EVEN : SCALE_UNEVEN);
                }
                else if (borderInsetXSlider.getSlider().equals(source))
                {
                    config.setBorderInsetX(borderInsetXSlider.getValue());
                }
                else if (borderInsetYSlider.getSlider().equals(source))
                {
                    config.setBorderInsetY(borderInsetYSlider.getValue());
                }
                else if (spaceWidthSlider.getSlider().equals(source))
                {
                    config.setSpaceWidth(spaceWidthSlider.getValue());
                }
                else if (baselineOffsetSlider.getSlider().equals(source))
                {
                    config.setBaselineOffset(baselineOffsetSlider.getValue());
                }
                else if (lineSpacingSlider.getSlider().equals(source))
                {
                    config.setLineSpacing(lineSpacingSlider.getValue());
                }
                else if (charSpacingSlider.getSlider().equals(source))
                {
                    config.setCharSpacing(charSpacingSlider.getValue());
                }
                else if (messageSpacingSlider.getSlider().equals(source))
                {
                    config.setMessageSpacing(messageSpacingSlider.getValue());
                }
                chat.repaint();
            }
        };

        ActionListener fontAl = new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                JMenuItem source = (JMenuItem) e.getSource();
                DropdownLabel key = new DropdownLabel(source.getText());
                if (CUSTOM_KEY.equals(key))
                {
                    int selectionResult = fontPngPicker.showDialog(ControlWindow.me, "Select Font PNG");
                    if (selectionResult == JFileChooser.APPROVE_OPTION)
                    {
                        fontFilenameInput.setText(fontPngPicker.getSelectedFile().getAbsolutePath());
                        fontPresetDropdown.setSelectedText(fontPngPicker.getSelectedFile().getName());
                    }
                }
                else
                {
                    fontFilenameInput.setText(PRESET_FONT_FILE_MAP.get(key).getFontFilename());
                    fontTypeCheckbox.setSelected(FontType.VARIABLE_WIDTH.equals(PRESET_FONT_FILE_MAP.get(key).getDefaultType()));
                    spaceWidthSlider.setEnabled(fontTypeCheckbox.isSelected());
                }
                updateFontOrBorder(true);
            }
        };

        ActionListener borderAl = new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                JMenuItem source = (JMenuItem) e.getSource();
                DropdownLabel key = new DropdownLabel(source.getText());
                if (CUSTOM_KEY.equals(key))
                {
                    int selectionResult = borderPngPicker.showDialog(ControlWindow.me, "Select Border PNG");
                    if (selectionResult == JFileChooser.APPROVE_OPTION)
                    {
                        borderFilenameInput.setText(borderPngPicker.getSelectedFile().getAbsolutePath());
                        borderPresetDropdown.setSelectedText(borderPngPicker.getSelectedFile().getName());
                    }
                }
                else
                {
                    DropdownBorder border = PRESET_BORDER_FILE_MAP.get(key);
                    borderFilenameInput.setText(border.getBorderFilename());
                    colorPanel.setBorderColor(border.getDefaultTint());
                }
                updateFontOrBorder(false);
            }
        };

        fontTypeListener = new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                config.setFontType(fontTypeCheckbox.isSelected() ? FontType.VARIABLE_WIDTH : FontType.FIXED_WIDTH);
                spaceWidthSlider.setEnabled(fontTypeCheckbox.isSelected());
                updateFontOrBorder(true);
            }
        };

        extendedCharBox = new JCheckBox("Display Extended Characters");
        extendedCharBox.addActionListener(new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                final boolean ecbSelected = extendedCharBox.isSelected();

                config.setExtendedCharEnabled(ecbSelected);
                unknownCharPopupButton.setEnabled(!ecbSelected);
                unknownCharLabel.setEnabled(!ecbSelected);
                chat.repaint();
            }
        });

        unknownCharLabel = new JLabel("");
        charPicker = new CharacterPicker(ControlWindow.me, fProps.getFontConfig(), unknownCharLabel, chat);

        Map<String, List<String>> fontMenuMap = getMenuMapFromPresets(PRESET_FONT_FILE_MAP.keySet());
        fontMenuMap.put(CUSTOM_KEY.getLabel(), null);
        Map<String, List<String>> borderMenuMap = getMenuMapFromPresets(PRESET_BORDER_FILE_MAP.keySet());
        borderMenuMap.put(CUSTOM_KEY.getLabel(), null);

        fontTypeCheckbox = new JCheckBox("Variable Width Characters");
        fontFilenameInput = new LabeledInput("Font Filename", 32);
        fontPresetDropdown = new ComboMenuBar(fontMenuMap, fontAl);
        borderFilenameInput = new LabeledInput("Border Filename", 32);
        borderPresetDropdown = new ComboMenuBar(borderMenuMap, borderAl);
        gridWidthInput = new LabeledInput("Grid Width", 4);
        gridHeightInput = new LabeledInput("Grid Height", 4);
        fontScaleSlider = new LabeledSlider("Font Size", "x", 1, ConfigFont.MAX_FONT_SCALE, ConfigFont.FONT_BORDER_SCALE_GRANULARITY);
        borderScaleSlider = new LabeledSlider("Border Size", "x", ConfigFont.MIN_BORDER_SCALE, ConfigFont.MAX_BORDER_SCALE, ConfigFont.FONT_BORDER_SCALE_GRANULARITY);
        borderInsetXSlider = new LabeledSlider("X", "pixels", ConfigFont.MIN_BORDER_INSET, ConfigFont.MAX_BORDER_INSET);
        borderInsetYSlider = new LabeledSlider("Y", "pixels", ConfigFont.MIN_BORDER_INSET, ConfigFont.MAX_BORDER_INSET);
        characterKeyInput = new LabeledInput("Character Key", 32);
        spaceWidthSlider = new LabeledSlider("Space Width", "%", ConfigFont.MIN_SPACE_WIDTH, ConfigFont.MAX_SPACE_WIDTH);
        baselineOffsetSlider = new LabeledSlider("Baseline Height Offset", "pixels", ConfigFont.MIN_BASELINE_OFFSET, ConfigFont.MAX_BASELINE_OFFSET);
        lineSpacingSlider = new LabeledSlider("Line Spacing", "pixels", ConfigFont.MIN_LINE_SPACING, ConfigFont.MAX_LINE_SPACING);
        charSpacingSlider = new LabeledSlider("Char Spacing", "pixels", ConfigFont.MIN_CHAR_SPACING, ConfigFont.MAX_CHAR_SPACING);
        messageSpacingSlider = new LabeledSlider("Message Spacing", "pixels", ConfigFont.MIN_MESSAGE_SPACING, ConfigFont.MAX_MESSAGE_SPACING);
        unknownCharPopupButton = new JButton("Select Missing Character");

        unknownCharPopupButton.addActionListener(new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                charPicker.setVisible(true);
            }
        });

        fontFilenameInput.setEnabled(false);
        borderFilenameInput.setEnabled(false);

        fontScaleSlider.addChangeListener(sliderListener);
        borderScaleSlider.addChangeListener(sliderListener);
        borderInsetXSlider.addChangeListener(sliderListener);
        borderInsetYSlider.addChangeListener(sliderListener);
        spaceWidthSlider.addChangeListener(sliderListener);
        baselineOffsetSlider.addChangeListener(sliderListener);
        lineSpacingSlider.addChangeListener(sliderListener);
        charSpacingSlider.addChangeListener(sliderListener);
        messageSpacingSlider.addChangeListener(sliderListener);

        JPanel fontPanel = new JPanel(new GridBagLayout());
        JPanel borderPanel = new JPanel(new GridBagLayout());
        JPanel unknownPanel = new JPanel(new GridBagLayout());

        fontPanel.setBorder(new TitledBorder(baseBorder, "Font"));
        borderPanel.setBorder(new TitledBorder(baseBorder, "Border"));
        unknownPanel.setBorder(new TitledBorder(baseBorder, "Extended and Unicode Characters"));

        GridBagConstraints fontGbc = new GridBagConstraints(0, 0, 1, 1, 0.0, 0.0, GridBagConstraints.NORTHWEST, GridBagConstraints.HORIZONTAL, DEFAULT_INSETS, 0, 0);
        GridBagConstraints borderGbc = new GridBagConstraints(0, 0, 1, 1, 0.0, 0.0, GridBagConstraints.NORTHWEST, GridBagConstraints.HORIZONTAL, DEFAULT_INSETS, 0, 0);
        GridBagConstraints unknownGbc = new GridBagConstraints(0, 0, 1, 1, 1.0, 0.0, GridBagConstraints.NORTHWEST, GridBagConstraints.HORIZONTAL, DEFAULT_INSETS, 0, 0);

        // Fields are still used and stored in properties files when saved,
        // but the values are either fixed or meant to be filled by other components

        // add(fontFilenameInput);
        // add(borderFilenameInput);
        // add(gridWidthInput);
        // add(gridHeightInput);
        // add(characterKeyInput);

        JPanel fontFilterAndDropdown = new JPanel(new GridBagLayout());
        GridBagConstraints ffadGbc = ControlPanelBase.getGbc();
        ffadGbc.anchor = GridBagConstraints.WEST;
        ffadGbc.fill = GridBagConstraints.NONE;
        ffadGbc.weightx = 0.0;
        fontFilterAndDropdown.add(fontPresetDropdown.getFilter(), ffadGbc);
        ffadGbc.gridx++;
        fontFilterAndDropdown.add(fontPresetDropdown, ffadGbc);

        fontPanel.add(fontFilterAndDropdown, fontGbc);
        fontGbc.gridx++;
        fontGbc.weightx = 1.0;
        fontGbc.fill = GridBagConstraints.HORIZONTAL;
        // This slider being on the same row as the preset dropdown keeps the combo menu bar from collapsing to no
        // height in the layout
        fontPanel.add(fontScaleSlider, fontGbc);
        fontGbc.gridwidth = 2;
        fontGbc.gridx = 0;
        fontGbc.gridy++;

        fontPanel.add(lineSpacingSlider, fontGbc);
        fontGbc.gridy++;
        fontPanel.add(charSpacingSlider, fontGbc);
        fontGbc.gridy++;
        fontPanel.add(messageSpacingSlider, fontGbc);
        fontGbc.gridy++;

        JPanel variableWidthPanel = new JPanel(new GridBagLayout());
        GridBagConstraints vwpGbc = getGbc();
        vwpGbc.anchor = GridBagConstraints.EAST;
        vwpGbc.weightx = 0.0;
        vwpGbc.fill = GridBagConstraints.NONE;
        variableWidthPanel.add(fontTypeCheckbox, vwpGbc);
        vwpGbc.anchor = GridBagConstraints.WEST;
        vwpGbc.weightx = 1.0;
        vwpGbc.fill = GridBagConstraints.HORIZONTAL;
        vwpGbc.gridx++;
        variableWidthPanel.add(spaceWidthSlider, vwpGbc);
        fontPanel.add(variableWidthPanel, fontGbc);
        fontGbc.gridy++;

        fontPanel.add(baselineOffsetSlider, fontGbc);
        fontGbc.gridy++;

        JPanel borderFilterAndDropdown = new JPanel(new GridBagLayout());
        GridBagConstraints bfadGbc = ControlPanelBase.getGbc();
        bfadGbc.anchor = GridBagConstraints.WEST;
        bfadGbc.fill = GridBagConstraints.NONE;
        bfadGbc.weightx = 0.0;
        borderFilterAndDropdown.add(borderPresetDropdown.getFilter(), bfadGbc);
        bfadGbc.gridx++;
        borderFilterAndDropdown.add(borderPresetDropdown, bfadGbc);

        borderPanel.add(borderFilterAndDropdown, borderGbc);
        borderGbc.gridx++;
        borderGbc.weightx = 1.0;
        borderGbc.fill = GridBagConstraints.HORIZONTAL;
        // This slider being on the same row as the preset dropdown keeps the combo menu bar from collapsing to no
        // height in the layout
        borderPanel.add(borderScaleSlider, borderGbc);
        borderGbc.gridwidth = 2;
        borderGbc.gridx = 0;
        borderGbc.gridy++;

        borderGbc.anchor = GridBagConstraints.CENTER;

        borderPanel.add(new JLabel("Font Insets Off Border"), borderGbc);
        borderGbc.gridy++;
        borderPanel.add(borderInsetXSlider, borderGbc);
        borderGbc.gridy++;
        borderPanel.add(borderInsetYSlider, borderGbc);
        borderGbc.gridy++;

        unknownPanel.add(extendedCharBox, unknownGbc);
        unknownGbc.gridx++;
        unknownPanel.add(unknownCharPopupButton, unknownGbc);
        unknownGbc.gridx++;
        unknownPanel.add(unknownCharLabel, unknownGbc);
        unknownGbc.gridx++;

        JPanel everything = new JPanel(new GridBagLayout());
        GridBagConstraints eGbc = new GridBagConstraints(0, 0, 1, 1, 1.0, 1.0, GridBagConstraints.NORTHWEST, GridBagConstraints.HORIZONTAL, DEFAULT_INSETS, 10, 10);
        everything.add(fontPanel, eGbc);
        eGbc.gridy++;
        everything.add(borderPanel, eGbc);
        eGbc.gridy++;
        everything.add(unknownPanel, eGbc);

        gbc.fill = GridBagConstraints.BOTH;
        gbc.anchor = GridBagConstraints.CENTER;
        gbc.weightx = 1.0;
        gbc.weighty = 0.0;
        add(everything, gbc);

        // Filler panel
        gbc.gridy++;
        gbc.weighty = 1.0;
        gbc.anchor = GridBagConstraints.SOUTH;
        add(new JPanel(), gbc);
    }

    /**
     * Turns a list of keys with a single pipe deliminating the parent menu from the menu item into a map that can be a
     * parameter to create a ComboMenuBar
     * 
     * @param keys
     * @return
     */
    private Map<String, List<String>> getMenuMapFromPresets(Collection<DropdownLabel> keys)
    {
        Map<String, List<String>> menuMap = new LinkedHashMap<String, List<String>>();
        for (DropdownLabel key : keys)
        {
            final String menu = key.getGroup();
            final String item = key.getLabel();
            List<String> items = menuMap.get(menu);
            if (items == null)
            {
                items = new ArrayList<String>();
                menuMap.put(menu, items);
            }
            items.add(item);
        }
        return menuMap;
    }

    private boolean updateFontOrBorder(boolean isFont)
    {
        LoadConfigReport report = new LoadConfigReport();
        if (isFont)
        {
            config.validateFontFile(report, fontFilenameInput.getText());
            config.validateStrings(report, gridWidthInput.getText(), gridHeightInput.getText(), characterKeyInput.getText(), Character.toString(charPicker.getSelectedChar()));
        }
        else
        {
            config.validateBorderFile(report, borderFilenameInput.getText());
        }

        if (report.isErrorFree())
        {
            try
            {
                fillConfigFromInput();
                if (isFont)
                {
                    chat.reloadFontFromConfig();
                }
                else
                {
                    chat.reloadBorderFromConfig();
                }
                chat.repaint();
                return true;
            }
            catch (Exception ex)
            {
                logger.error(ex.toString(), ex);
                ChatWindow.popup.handleProblem("Unable to load " + (isFont ? "font" : "border") + " image", ex);
            }
        }
        else
        {
            ChatWindow.popup.handleProblem(report);
        }
        return false;
    }

    @Override
    protected void fillInputFromProperties(FontificatorProperties fProps)
    {
        this.config = fProps.getFontConfig();
        fillInputFromConfig();
    }

    @Override
    protected void fillInputFromConfig()
    {
        borderFilenameInput.setText(config.getBorderFilename());
        fontFilenameInput.setText(config.getFontFilename());
        gridWidthInput.setText(Integer.toString(config.getGridWidth()));
        gridHeightInput.setText(Integer.toString(config.getGridHeight()));
        fontScaleSlider.setScaledValue(config.getFontScale());
        borderScaleSlider.setScaledValue(config.getBorderScale());
        borderInsetXSlider.setValue(config.getBorderInsetX());
        borderInsetYSlider.setValue(config.getBorderInsetY());
        characterKeyInput.setText(config.getCharacterKey());
        extendedCharBox.setSelected(config.isExtendedCharEnabled());
        charPicker.setSelectedChar(config.getUnknownChar());
        spaceWidthSlider.setValue(config.getSpaceWidth());
        baselineOffsetSlider.setValue(config.getBaselineOffset());
        lineSpacingSlider.setValue(config.getLineSpacing());
        charSpacingSlider.setValue(config.getCharSpacing());
        messageSpacingSlider.setValue(config.getMessageSpacing());
        fontTypeCheckbox.setSelected(FontType.VARIABLE_WIDTH.equals(config.getFontType()));
        spaceWidthSlider.setEnabled(fontTypeCheckbox.isSelected());
        final boolean ecbSelected = extendedCharBox.isSelected();
        unknownCharPopupButton.setEnabled(!ecbSelected);
        unknownCharLabel.setEnabled(!ecbSelected);

        boolean found;

        found = false;
        for (Map.Entry<DropdownLabel, DropdownFont> entry : PRESET_FONT_FILE_MAP.entrySet())
        {
            if (entry.getValue() != null && config.getFontFilename().equals(entry.getValue().getFontFilename()))
            {
                found = true;
                fontPresetDropdown.setSelectedText(entry.getKey().getLabel());
            }
        }
        if (!found)
        {
            String filename = new File(fontFilenameInput.getText()).getName();
            fontPresetDropdown.setSelectedText(filename);
        }

        found = false;
        for (Map.Entry<DropdownLabel, DropdownBorder> entry : PRESET_BORDER_FILE_MAP.entrySet())
        {
            if (entry.getValue() != null && config.getBorderFilename().equals(entry.getValue().getBorderFilename()))
            {
                found = true;
                borderPresetDropdown.setSelectedText(entry.getKey().getLabel());
            }
        }
        if (!found)
        {
            String filename = new File(borderFilenameInput.getText()).getName();
            borderPresetDropdown.setSelectedText(filename);
        }

        // Although the font was already updated from the listener attached the the fontTypeDropdown, it should be done
        // here to make it official. If the font and border aren't updated, they could be out of sync with the input
        // filled from config on preset loads, and it shouldn't be the responsibility of actionlisteners attached to UI
        // components to update the display
        updateFontOrBorder(true);

        // Also, the border must be updated here too.
        updateFontOrBorder(false);
    }

    @Override
    protected LoadConfigReport validateInput()
    {
        LoadConfigReport report = new LoadConfigReport();

        config.validateFontFile(report, fontFilenameInput.getText());
        config.validateBorderFile(report, borderFilenameInput.getText());

        final String widthStr = gridWidthInput.getText();
        final String heightStr = gridHeightInput.getText();
        final String charKey = characterKeyInput.getText();
        final String unknownStr = Character.toString(charPicker.getSelectedChar());

        config.validateStrings(report, widthStr, heightStr, charKey, unknownStr);

        return report;
    }

    @Override
    protected void fillConfigFromInput() throws Exception
    {
        config.setBorderFilename(borderFilenameInput.getText());
        config.setFontFilename(fontFilenameInput.getText());
        config.setFontType(fontTypeCheckbox.isSelected() ? FontType.VARIABLE_WIDTH : FontType.FIXED_WIDTH);
        config.setGridWidth(Integer.parseInt(gridWidthInput.getText()));
        config.setGridHeight(Integer.parseInt(gridHeightInput.getText()));
        config.setFontScale(fontScaleSlider.getScaledValue());
        config.setBorderScale(borderScaleSlider.getScaledValue());
        config.setBorderInsetX(borderInsetXSlider.getValue());
        config.setBorderInsetY(borderInsetYSlider.getValue());
        config.setCharacterKey(characterKeyInput.getText());
        config.setExtendedCharEnabled(extendedCharBox.isSelected());
        config.setUnknownChar(charPicker.getSelectedChar());
        config.setSpaceWidth(spaceWidthSlider.getValue());
        config.setBaselineOffset(baselineOffsetSlider.getValue());
        config.setLineSpacing(lineSpacingSlider.getValue());
        config.setCharSpacing(charSpacingSlider.getValue());
        config.setMessageSpacing(messageSpacingSlider.getValue());
    }

} public class ControlTabs extends JTabbedPane
{
    private static final Logger logger = Logger.getLogger(ControlTabs.class);

    private static final long serialVersionUID = 1L;

    /**
     * The bot that reads from the IRC channel and relays the messages to the chat view
     */
    private ChatViewerBot bot;

    /**
     * The reference to the properties to be passed into each control panel
     */
    private FontificatorProperties fProps;

    /**
     * An array containing each of the control panels
     */
    private ControlPanelBase[] subpanels;

    /**
     * A reference to the connection control panel, containing the specifications for connecting to the channel
     */
    private ControlPanelIrc ircPanel;

    /**
     * A reference to the chat control panel, containing all the chat window options
     */
    private ControlPanelChat chatPanel;

    /**
     * A reference to the font/border control panel, containing all the options for specifying the font and border
     */
    private ControlPanelFont fontPanel;

    /**
     * A reference to the color control panel, containing all the options for specifying the colors used in the chat
     * view
     */
    private ControlPanelColor colorPanel;

    /**
     * A reference to the message control panel, containing all the options for formatting the chat messages
     */
    private ControlPanelMessage messagePanel;

    /**
     * A reference to the emoji control panel, containing all the options for whether to and how to display emoji in the
     * chat messages
     */
    private ControlPanelEmoji emojiPanel;

    /**
     * A reference to the debug control panel, hidden until selected from the Help menu option to enter debug mode,
     * containing all the options for debugging
     */
    private ControlPanelDebug debugPanel;

    /**
     * A reference to the message censorship control panel. This panel is not in a tab, but is accessible via the
     * Message menu item. Nevertheless, it must be updated from the config objects in the same way as the tabbed control
     * panels, so it is managed here in this tabs object.
     */
    private MessageCensorPanel censorPanel;

    private LogBox logBox;

    /**
     * Construct a ControlTabs object to create all the control panel tabs
     * 
     * @param fProps
     * @param bot
     */
    public ControlTabs(FontificatorProperties fProps, ChatViewerBot bot, MessageCensorPanel censorPanel, LogBox logBox)
    {
        super(TOP, SCROLL_TAB_LAYOUT);
        this.fProps = fProps;
        this.bot = bot;
        this.censorPanel = censorPanel;
        this.logBox = logBox;
    }

    public void build(ChatWindow chatWindow, ControlWindow ctrlWindow)
    {
        logger.trace("Building ControlMainPanel");

        emojiPanel = new ControlPanelEmoji(fProps, chatWindow, bot, logBox);
        ircPanel = new ControlPanelIrc(fProps, chatWindow, emojiPanel, bot, logBox);
        logBox.setAuthCode(fProps.getIrcConfig().getAuthorization());
        chatPanel = new ControlPanelChat(fProps, chatWindow, ctrlWindow, logBox);
        colorPanel = new ControlPanelColor(fProps, chatWindow, logBox);
        fontPanel = new ControlPanelFont(fProps, chatWindow, logBox, colorPanel);
        messagePanel = new ControlPanelMessage(fProps, chatWindow, bot, logBox);
        debugPanel = new ControlPanelDebug(fProps, chatWindow, ctrlWindow);

        subpanels = new ControlPanelBase[6];

        subpanels[0] = ircPanel;
        subpanels[1] = chatPanel;
        subpanels[2] = fontPanel;
        subpanels[3] = messagePanel;
        subpanels[4] = colorPanel;
        subpanels[5] = emojiPanel;

        for (int i = 0; i < subpanels.length; i++)
        {
            addControlTab(subpanels[i]);
        }
    }

    public void refreshUiFromConfig(FontificatorProperties fProps)
    {
        for (int i = 0; i < subpanels.length; i++)
        {
            subpanels[i].fillInputFromConfig();
        }
        censorPanel.fillInputFromConfig();
    }

    public boolean refreshConfigFromUi()
    {
        LoadConfigReport report = new LoadConfigReport();
        for (int i = 0; i < subpanels.length; i++)
        {
            report.addFromReport(subpanels[i].validateInput());
        }

        if (report.isErrorFree())
        {
            for (int i = 0; i < subpanels.length; i++)
            {
                try
                {
                    subpanels[i].fillConfigFromInput();
                }
                catch (Exception e)
                {
                    report.addError("Excepton thrown trying to interpret input on the " + subpanels[i].getLabel() + " tab", LoadConfigErrorType.UNKNOWN_ERROR);
                }
            }
        }

        if (!report.isErrorFree())
        {
            ChatWindow.popup.handleProblem(report);
        }

        return report.isErrorFree();
    }

    public void setAlwaysOnTopConfig(boolean alwaysOnTop)
    {
        chatPanel.setAlwaysOnTop(alwaysOnTop);
    }

    public void setRememberChatWindowPosition(boolean rememberPosition)
    {
        chatPanel.setRememberPosition(rememberPosition);
    }

    public void setChatWindowPosition()
    {
        chatPanel.setRememberedPosition();
    }

    public void setAntiAlias(boolean antiAlias)
    {
        chatPanel.setAntiAlias(antiAlias);
    }

    public void toggleDebugTab()
    {
        int debugTabIndex = indexOfComponent(debugPanel);
        if (debugTabIndex < 0)
        {
            addControlTab(debugPanel);
            debugPanel.setDebugging(true);
        }
        else
        {
            removeTabAt(debugTabIndex);
            debugPanel.setDebugging(false);
        }
    }

    /**
     * Adds a control panel tab to the control tabs, using its label as the tab name
     * 
     * @param cpTab tab to add
     */
    private void addControlTab(ControlPanelBase cpTab)
    {
        addTab(cpTab.getLabel(), cpTab);
    }

    public ControlPanelDebug getDebugTab()
    {
        return debugPanel;
    }
} public class LogBox extends JScrollPane
{
    private static final long serialVersionUID = 1L;

    private JTextArea output;

    private String authCode;

    public LogBox()
    {
        super(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);

        this.authCode = null;

        output = new JTextArea();
        output.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
        output.setWrapStyleWord(true);
        output.setLineWrap(true);
        output.setEditable(false);
        output.setBackground(getBackground());

        super.setViewportView(output);
    }

    public void setAuthCode(String authCode)
    {
        this.authCode = authCode;
    }

    public void log(LoadConfigReport report)
    {
        for (String msg : report.getMessages())
        {
            log(msg);
        }
    }

    public void log(String line)
    {
        if (authCode != null && line.contains(authCode))
        {
            String blocks = "";
            for (int i = 0; i < authCode.length(); i++)
            {
                blocks += "*";
            }
            // Blank out any time the oauth key is logged- this output might accidentally wind up in a video stream
            line = line.replaceAll(authCode, blocks);
        }
        if (line.endsWith("\n"))
        {
            line = line.substring(0, line.length() - 1);
        }
        output.append((output.getText().isEmpty() ? "" : "\n") + line);
        output.setCaretPosition(output.getDocument().getLength());
    }

    public void clear()
    {
        output.setText("");
    }

}
import java.awt.Color;
import java.awt.Component;
import java.awt.Desktop;
import java.awt.Event;
import java.awt.Graphics;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.GridLayout;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyEvent;
import java.awt.image.BufferedImage;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageTypeSpecifier;
import javax.imageio.ImageWriter;
import javax.imageio.metadata.IIOMetadata;
import javax.imageio.metadata.IIOMetadataNode;
import javax.imageio.stream.ImageOutputStream;
import javax.swing.JButton;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JDialog;
import javax.swing.JEditorPane;
import javax.swing.JFileChooser;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JScrollPane;
import javax.swing.JSeparator;
import javax.swing.KeyStroke;
import javax.swing.event.HyperlinkEvent;
import javax.swing.event.HyperlinkEvent.EventType;
import javax.swing.event.HyperlinkListener;
import javax.swing.filechooser.FileFilter;
import javax.swing.filechooser.FileNameExtensionFilter;
import org.apache.log4j.Logger;
import com.glitchcog.fontificator.bot.ChatViewerBot;
import com.glitchcog.fontificator.bot.MessageType;
import com.glitchcog.fontificator.bot.TwitchPrivmsg;
import com.glitchcog.fontificator.config.FontificatorProperties;
import com.glitchcog.fontificator.config.loadreport.LoadConfigErrorType;
import com.glitchcog.fontificator.config.loadreport.LoadConfigReport;
import com.glitchcog.fontificator.gui.AssetIndexLoader;
import com.glitchcog.fontificator.gui.chat.ChatPanel;
import com.glitchcog.fontificator.gui.chat.ChatWindow;
import com.glitchcog.fontificator.gui.component.MenuComponent;
import com.glitchcog.fontificator.gui.controls.messages.MessageDialog;
import com.glitchcog.fontificator.gui.controls.panel.ControlPanelDebug;
import com.glitchcog.fontificator.gui.controls.panel.ControlPanelFont;
import com.glitchcog.fontificator.gui.controls.panel.ControlTabs;
import com.glitchcog.fontificator.gui.controls.panel.LogBox;
package com.glitchcog.fontificator.gui.controls;





/**
 * This is the window for containing all the options of the chat display window
 * 
 * @author Matt Yanos
 */
public class ControlWindow extends JDialog
{
    private static final Logger logger = Logger.getLogger(ControlWindow.class);

    private static final long serialVersionUID = 1L;

    private final String DEFAULT_CONFIG_FILE_EXTENSION = "cgf";

    private final String DEFAULT_SCREENSHOT_FILE_EXTENSION = "png";

    private ControlTabs controlTabs;

    private ChatViewerBot bot;

    private ChatWindow chatWindow;

    private MessageDialog messageDialog;