public class Message
{
    private static final Logger logger = Logger.getLogger(Message.class);

    private static final long UNCOMPLETED_TIME = 0L;

    /**
     * A regex for checking for emoji keys in the text. Used in a String.split to divide the message into an array of
     * words and the spaces between them.
     */
    public static final String SPACE_BOUNDARY_REGEX = "(?:(?=\\s+)(?<!\\s+)|(?<=\\s+)(?!\\s+))";

    /**
     * Why doesn't this catch 🤖 (U+0x1f916)?
     */
    private static final String TWITTER_EMOJI_REGEX = "((([\uD83C\uDF00-\uD83D\uDDFF]|[\uD83D\uDE00-\uD83D\uDE4F]|[\uD83D\uDE80-\uD83D\uDEFF]|[\u2600-\u26FF]|[\u2700-\u27BF])[\\x{1F3FB}-\\x{1F3FF}]?))";
    private static final Pattern TWITTER_EMOJI_PATTERN = Pattern.compile(TWITTER_EMOJI_REGEX);

    /**
     * The state of the user that is prepended to the message from Twitch. This reference is the same one that's stored
     * in the ChatViewerBot, so it is possible to update that object and see the effects on this message
     */
    private final TwitchPrivmsg privmsg;

    /**
     * Whether this message is censored, meaning not displayed at all in the chat
     */
    private boolean censored;

    /**
     * Whether a censored message was purged from the chat
     */
    private boolean purged;

    /**
     * The reason for the censorship
     */
    private String censoredReason;

    /**
     * Whether this message has been manually censored or uncensored
     */
    private boolean manualCensorship;

    /**
     * The username of the poster, or the username of the user who joined, if the message is a join message
     */
    private final String rawUsername;

    /**
     * A time stamp of when the message was created
     */
    private final Date timestamp;

    /**
     * The text content of the message
     */
    private final String content;

    /**
     * The type of the message
     */
    private final MessageType type;

    /**
     * The badges to draw, the size of which is used to keep track of the position of the username, which is used for
     * coloring. This value is calculated when the text is parsed into SpriteCharacterKeys and will be null if all
     * badges are switched off.
     */
    private Map<String, LazyLoadEmoji> badges;

    /**
     * The String of the message put into text
     */
    private SpriteCharacterKey[] text;

    /**
     * The text that goes between a time stamp and a username
     */
    private static final String TIMESTAMP_USERNAME_SPACER = " ";

    /**
     * The maximum possible value of an int cast into a float, used to max out the character count if the message speed
     * is maxed out
     */
    private static final float MAX_INT_AS_FLOAT = (float) Integer.MAX_VALUE;

    /**
     * How much of the message should be drawn
     */
    private float drawCursor;

    /**
     * Keep track of the configuration the last time this message was parsed, so if no configuration has changed (check
     * using ConfigMessage.equals), then there's no need to re-parse it
     */
    private ConfigMessage lastMessageConfig;

    /**
     * Keep track of the configuration the last time this message was parsed, so if no configuration has changed (check
     * using ConfigEmoji.equals), then there's no need to re-parse it
     */
    private ConfigEmoji lastEmojiConfig;

    /**
     * The moment in time the message was completely drawn
     */
    private long completedTime;

    /**
     * Construct a message specifying the type, username and content, but set the time stamp to the current local time
     * 
     * @param type
     *            The type of this message
     * @param rawUsername
     *            The username of whomever posted this message
     * @param content
     *            The text of the message
     * @param privmsg
     *            The Twitch Privmsg object, will not be null
     */
    public Message(MessageType type, String rawUsername, String content, TwitchPrivmsg privmsg)
    {
        this(type, rawUsername, new Date(), content, privmsg);
    }

    /**
     * Construct a message specifying everything
     * 
     * @param type
     *            The type of this message
     * @param rawUsername
     *            The username of whomever posted this message
     * @param timestamp
     *            When the message was posted (local time)
     * @param content
     *            The text of the message
     * @param privmsg
     *            The Twitch Privmsg object, will not be null
     */
    public Message(MessageType type, String rawUsername, Date timestamp, String content, TwitchPrivmsg privmsg)
    {
        this.type = type;
        this.rawUsername = rawUsername;
        this.timestamp = timestamp;
        this.content = content;
        this.drawCursor = 0.0f;
        this.lastMessageConfig = new ConfigMessage();
        this.lastEmojiConfig = new ConfigEmoji();
        this.privmsg = privmsg;
        this.completedTime = UNCOMPLETED_TIME;
    }

    /**
     * Get whether the message is a join type message
     * 
     * @return joinType
     */
    public boolean isJoinType()
    {
        return MessageType.JOIN.equals(type);
    }

    /**
     * Get the type of the message
     * 
     * @return type
     */
    public MessageType getType()
    {
        return type;
    }

    /**
     * Get the username of the poster, or the username of the user who joined, if the message is a join message
     * 
     * @return username
     */
    public String getUsername()
    {
        return rawUsername;
    }

    /**
     * Increment the draw cursor based on the message text as defined by the specified messageConfig and its message
     * speed setting
     * 
     * @param messageConfig
     * @param emojiConfig
     */
    public void incrementDrawCursor(EmojiManager emojiManager, ConfigMessage messageConfig, ConfigEmoji emojiConfig)
    {
        float characterCount = 1.0f;

        // If the message speed is zero, then it's max, so draw everything
        if (messageConfig.getMessageSpeed() < 1)
        {
            characterCount = MAX_INT_AS_FLOAT;
        }
        // If this is true, then multiple characters need to be drawn per update
        else if (1000L / messageConfig.getMessageSpeed() < ConfigMessage.SHORTEST_DELAY)
        {
            characterCount = (float) messageConfig.getMessageSpeed() * (float) ConfigMessage.SHORTEST_DELAY / 1000.0f;
        }

        drawCursor += characterCount;

        // Whether the message is completely drawn yet needs to be calculated
        // and set as a member variable once true so that configuration changes
        // that modify the length of the message do not re-trigger drawing bits
        // of past messages
        if (drawCursor >= getMessageLength(emojiManager, messageConfig, emojiConfig))
        {
            completedTime = System.currentTimeMillis();
        }
    }

    /**
     * Get message length in characters. Emoji are considered single characters.
     * 
     * @param emojiManager
     * @param messageConfig
     * @param emojiConfig
     * @return length
     */
    public int getMessageLength(EmojiManager emojiManager, ConfigMessage messageConfig, ConfigEmoji emojiConfig)
    {
        return getText(emojiManager, messageConfig, emojiConfig).length;
    }

    /**
     * Get the draw cursor, or the max draw cursor value if the message is completely drawn
     * 
     * @return drawCursor
     */
    public float getDrawCursor()
    {
        return isCompletelyDrawn() ? MAX_INT_AS_FLOAT : drawCursor;
    }

    /**
     * Get whether the message is completely drawn or not. Because the fully displayed message can change, like if the
     * option to show the time stamp is selected on the fly, once any configuration of the message is fully displayed,
     * it should remain fully displayed, even though the draw cursor won't be at the end of the now longer message text.
     * 
     * @return completelyDrawn
     */
    public boolean isCompletelyDrawn()
    {
        return completedTime != 0;
    }

    public void setCompletelyDrawn()
    {
        drawCursor = Integer.MAX_VALUE;
        completedTime = System.currentTimeMillis();
    }

    /**
     * Get the time stamp
     * 
     * @return timestamp
     */
    public Date getTimestamp()
    {
        return timestamp;
    }

    /**
     * Get the string representation of the time stamp using the
     * 
     * @param messageConfig
     * @return timestampString
     */
    public String getTimestampString(ConfigMessage messageConfig)
    {
        return messageConfig.getTimerFormatter().format(timestamp);
    }

    /**
     * Get the content of the message
     * 
     * @return content
     */
    public String getContent()
    {
        return content;
    }

    @Override
    public String toString()
    {
        return rawUsername + ": " + content;
    }

    /**
     * Get the index right after the username based on the specified messageConfig
     * 
     * @param messageConfig
     * @return
     */
    public int getIndexTimestamp(ConfigMessage messageConfig)
    {
        return messageConfig.showTimestamps() ? getTimestampString(messageConfig).length() : 0;
    }

    /**
     * Get the indexes for when the username starts and right after the username based on the specified messageConfig
     * 
     * @param messageConfig
     * @return username index
     */
    public int[] getIndexUsername(ConfigMessage messageConfig)
    {
        int start = badges == null ? 0 : badges.size();
        int end;
        if (messageConfig.showTimestamps())
        {
            start += getIndexTimestamp(messageConfig);
        }
        if (messageConfig.showUsernames())
        {
            if (messageConfig.showTimestamps())
            {
                start += TIMESTAMP_USERNAME_SPACER.length();
            }
            final String usernameFormat = messageConfig.getUsernameFormat();
            if (usernameFormat.indexOf(ConfigMessage.USERNAME_REPLACE) > 0)
            {
                final int distanceIntoFormat = usernameFormat.indexOf(ConfigMessage.USERNAME_REPLACE);
                start += distanceIntoFormat;
            }
            end = start;
            end += rawUsername.length();
            return new int[] { start, end };
        }
        else
        {
            return new int[] { start, start };
        }

    }

    /**
     * Get the full text of the message to display based on the specified messageConfig
     * 
     * @param emojiManager
     * @param messageConfig
     * @param emojiConfig
     * @return message text
     */
    public SpriteCharacterKey[] getText(EmojiManager emojiManager, ConfigMessage messageConfig, ConfigEmoji emojiConfig)
    {
        if (text != null && !isConfigChanged(messageConfig, emojiConfig))
        {
            return text;
        }
        else
        {
            text = parseIntoText(emojiManager, messageConfig, emojiConfig);
            this.lastMessageConfig.deepCopy(messageConfig);
            this.lastEmojiConfig.deepCopy(emojiConfig);
            return text;
        }
    }

    /**
     * Get whether the configuration has changed
     * 
     * @param messageConfig
     * @param emojiConfig
     * @return changed
     */
    private boolean isConfigChanged(ConfigMessage messageConfig, ConfigEmoji emojiConfig)
    {
        final boolean messageConfigChanged = !messageConfig.equals(lastMessageConfig);
        final boolean emojiConfigChanged = !emojiConfig.equals(lastEmojiConfig);
        return messageConfigChanged || emojiConfigChanged;
    }

    /**
     * Compile the array of SpriteCharacterKeys using the specified configuration. This can be a bit memory intensive
     * since each character is a new albeit small object, so this should not be done many times a second, rather only if
     * something has changed in the configuration to warrant a re-translation.
     * 
     * @param emojiManager
     * @param messageConfig
     * @param emojiConfig
     * @return spriteCharacterKeys
     */
    private SpriteCharacterKey[] parseIntoText(EmojiManager emojiManager, ConfigMessage messageConfig, ConfigEmoji emojiConfig)
    {
        List<SpriteCharacterKey> keyList = new ArrayList<SpriteCharacterKey>();

        if (messageConfig.showTimestamps())
        {
            String timeStampStr = messageConfig.getTimerFormatter().format(timestamp);
            timeStampStr = applyCasing(timeStampStr, messageConfig.getMessageCasing());
            keyList.addAll(toSpriteArray(timeStampStr));
        }

        // Add badges to be placed right before the username
        if (emojiConfig.isAnyBadgesEnabled())
        {
            // LinkedHashMap to preserve original insert order
            badges = new LinkedHashMap<String, LazyLoadEmoji>();

            final boolean userIsModerator = privmsg.getUserType() == UserType.MOD;

            // Bank to pull Twitch badges from
            TypedEmojiMap twitchBadgeBank = emojiManager.getEmojiByType(EmojiType.TWITCH_BADGE);
            // Bank to pull FrankerFaceZ badges from
            TypedEmojiMap ffzBadgeBank = emojiManager.getEmojiByType(EmojiType.FRANKERFACEZ_BADGE);

            // Get the badge for the type of user, if the usertype has a badge (but not if it's an ffzBot)
            if (privmsg.getUserType() != null && privmsg.getUserType() != UserType.NONE)
            {
                LazyLoadEmoji testBadge;
                // FFZ badges are enabled, the user is a moderator, and the custom FFZ moderator badge exists
                if (emojiConfig.isFfzBadgesEnabled() && userIsModerator && ffzBadgeBank.getEmoji(UserType.MOD.getKey()) != null)
                {
                    badges.put(UserType.MOD.getKey(), ffzBadgeBank.getEmoji(UserType.MOD.getKey()));
                }
                else if (emojiConfig.isTwitchBadgesEnabled() && (testBadge = twitchBadgeBank.getEmoji(privmsg.getUserType().getKey())) != null)
                {
                    badges.put(privmsg.getUserType().getKey(), testBadge);
                }
            }

            LazyLoadEmoji replacementBadge = null;

            if (emojiConfig.isFfzBadgesEnabled())
            {
                Map<Integer, Set<String>> ffzBadgeUsers = emojiManager.getFfzBadgeUsers();
                for (Integer ffzBadgeType : ffzBadgeUsers.keySet())
                {
                    final String ffzBadgeKey = ffzBadgeType == null ? null : Integer.toString(ffzBadgeType);
                    Set<String> users = ffzBadgeUsers.get(ffzBadgeType);
                    if (users.contains(rawUsername.toLowerCase()))
                    {
                        LazyLoadEmoji ffzBadge = ffzBadgeBank.getEmoji(ffzBadgeType);
                        if (ffzBadge.isReplacement() && badges.containsKey(ffzBadge.getReplaces()))
                        {
                            badges.put(ffzBadge.getReplaces(), ffzBadge);
                            if (userIsModerator && true)
                            {
                                replacementBadge = ffzBadge;
                            }
                        }
                        else
                        {
                            badges.put(ffzBadgeKey, ffzBadge);
                        }
                    }
                }
            }

            // Optional subscriber badge
            if (emojiConfig.isTwitchBadgesEnabled())
            {
                final String subStr = "subscriber";
                if (privmsg.isSubscriber() && twitchBadgeBank.getEmoji(subStr) != null)
                {
                    badges.put(subStr, twitchBadgeBank.getEmoji(subStr));
                }
            }

            // Optional turbo badge
            final String turboStr = "turbo";
            if (emojiConfig.isTwitchBadgesEnabled() && privmsg.isTurbo() && twitchBadgeBank.getEmoji(turboStr) != null)
            {
                badges.put(turboStr, twitchBadgeBank.getEmoji(turboStr));
            }

            final String primeStr = "prime";
            if (emojiConfig.isTwitchBadgesEnabled() && privmsg.isPrime() && twitchBadgeBank.getEmoji(primeStr) != null)
            {
                badges.put(primeStr, twitchBadgeBank.getEmoji(primeStr));
            }

            // Add each badges map item onto the sprite character key list
            for (LazyLoadEmoji lle : badges.values())
            {
                SpriteCharacterKey sck = new SpriteCharacterKey(lle, true);
                if (userIsModerator && lle == replacementBadge)
                {
                    sck.setEmojiBgColorOverride(ConfigEmoji.MOD_BADGE_COLOR);
                }
                keyList.add(sck);
            }
        }

        if (messageConfig.showUsernames())
        {
            if (messageConfig.showTimestamps())
            {
                keyList.addAll(toSpriteArray(TIMESTAMP_USERNAME_SPACER));
            }
            String casedUsername = getFormattedUsername(messageConfig);
            keyList.addAll(toSpriteArray(casedUsername));
        }
        if (messageConfig.showUsernames() || messageConfig.showTimestamps() || (emojiConfig.isAnyBadgesEnabled() && badges != null && !badges.isEmpty()))
        {
            if (type == MessageType.NORMAL || type == MessageType.MANUAL)
            {
                keyList.addAll(toSpriteArray(messageConfig.getContentBreaker()));
            }
            else
            {
                keyList.addAll(toSpriteArray(type.getContentBreaker()));
            }
        }

        // Parse out the emoji, if enabled
        if (emojiConfig.isEmojiEnabled())
        {
            processEmoji(content, privmsg, keyList, emojiManager, emojiConfig, MessageType.MANUAL.equals(type), messageConfig.getMessageCasing());
        }
        // Configured for no emoji, so just chars
        else
        {
            keyList.addAll(toSpriteArray(applyCasing(content, messageConfig.getMessageCasing())));
        }

        // Return the list as an array, to be kept until configuration is modified requiring a reprocessing
        return keyList.toArray(new SpriteCharacterKey[keyList.size()]);
    }

    private static String[] codePointSpaceSplit(String content)
    {
        List<String> words = new ArrayList<String>();
        if (!content.isEmpty())
        {
            int breakIndex = 0;
            int c = 0;
            boolean currentlyInWord = !Character.isWhitespace(content.codePointAt(c));
            while (c < content.length())
            {
                final int codePoint = content.codePointAt(c);
                final boolean isWhitespace = Character.isWhitespace(codePoint);
                final int charSize = Character.charCount(codePoint);

                if ((currentlyInWord && isWhitespace) || (!currentlyInWord && !isWhitespace))
                {
                    words.add(content.substring(breakIndex, c));
                    breakIndex = c;
                    currentlyInWord = !currentlyInWord;
                }

                c += charSize;
            }
            words.add(content.substring(breakIndex, content.length()));
        }
        return words.toArray(new String[words.size()]);
    }

    /**
     * Convert the content of the message into the appropriate emoji. Add those emoji and the remaining characters
     * between them to the specified keyList array.
     * 
     * @param content
     * @param privmsg
     * @param keyList
     *            The array to add the emoji and remaining characters to
     * @param emojiManager
     * @param emojiConfig
     * @param isManualMessage
     */
    private static void processEmoji(String content, TwitchPrivmsg privmsg, List<SpriteCharacterKey> keyList, EmojiManager emojiManager, ConfigEmoji emojiConfig, boolean isManualMessage, MessageCasing casing)
    {
        Map<Integer, EmoteAndIndices> emotes = privmsg.getEmotes();

        String[] words = codePointSpaceSplit(content); // content.split(SPACE_BOUNDARY_REGEX);

        int codeIndex = 0;

        LazyLoadEmoji emoji = null;
        for (int w = 0; w < words.length; w++)
        {
            EmoteAndIndices eai = emotes.get(codeIndex);
            if (eai != null && emojiConfig.isTwitchEnabled())
            {
                // This catches subscriber emotes and any non-global emotes
                emoji = emojiManager.getEmojiById(eai.getEmoteId(), words[w], emojiConfig);
                if (emoji == null)
                {
                    // The already loaded Twitch V1 emoji map doesn't have this emote ID yet, so add it
                    try
                    {
                        emoji = emojiManager.putEmojiById(eai.getEmoteId(), words[w], emojiConfig);
                    }
                    catch (MalformedURLException e)
                    {
                        logger.error("Unable to load emote for emote ID " + eai.getEmoteId(), e);
                    }
                }
            }
            // At this point, only 3rd party emoji should be a possibility for this word (with the exception of
            // manual messages)
            else
            {
                // This is the manual message exception
                if (isManualMessage)
                {
                    // As a known bug here, all manual messages will have access to all Twitch emotes, regardless of
                    // subscriber status
                    emoji = emojiManager.getEmojiWords(EmojiType.MANUAL_EMOJI_TYPES, words[w], emojiConfig);
                }
                // Only check 3rd party emotes
                else
                {
                    emoji = emojiManager.getEmojiWords(EmojiType.THIRD_PARTY_EMOJI_TYPES, words[w], emojiConfig);
                }
            }

            // If a word-emoji has not yet been found and Twitter single-character emoji are enabled, then process them
            // within the word
            if (emoji == null)
            {
                Matcher matcher = TWITTER_EMOJI_PATTERN.matcher(words[w]);
                if (emojiConfig.isTwitterEnabled() && matcher.find())
                {
                    matcher.reset();

                    String twitterEmojiUrl = null;
                    int i = 0;
                    while (matcher.find())
                    {
                        String rawCode = matcher.group(2);
                        String iconId = toCodePoint(rawCode.indexOf('\u200D') < 0 ? rawCode.replace("\uFE0F", "") : rawCode);
                        twitterEmojiUrl = "https://twemoji.maxcdn.com/2/" + "72x72" + "/" + iconId + ".png";
                        LazyLoadEmoji lle = emojiManager.getEmojiByType(EmojiType.TWITTER_EMOJI).getEmoji(iconId);
                        if (lle == null)
                        {
                            try
                            {
                                lle = new LazyLoadEmoji(iconId, twitterEmojiUrl, EmojiType.TWITTER_EMOJI);
                                emojiManager.getEmojiByType(EmojiType.TWITTER_EMOJI).put(iconId, lle);
                            }
                            catch (MalformedURLException e)
                            {
                                logger.error("Couldn't parse emoji URL: " + twitterEmojiUrl, e);
                            }
                        }
                        final String wordBit = words[w].substring(i, matcher.start());

                        keyList.addAll(toSpriteArray(applyCasing(wordBit, casing)));
                        if (lle != null)
                        {
                            keyList.add(new SpriteCharacterKey(lle, false));
                        }
                        i = matcher.end();
                    }
                    final String wordBit = words[w].substring(i, words[w].length());
                    keyList.addAll(toSpriteArray(applyCasing(wordBit, casing)));
                }
                else
                {
                    keyList.addAll(toSpriteArray(applyCasing(words[w], casing)));
                }
            }

            if (emoji != null)
            {
                keyList.add(new SpriteCharacterKey(emoji, false));
            }

            // Increment the codeIndex by the current word's code point count
            // Emoji indexes are in code points, but java is counting in fixed 16-bit units
            codeIndex += words[w].codePointCount(0, words[w].length());
        }
    }

    /**
     * From https://gist.github.com/heyarny/71c246f2f7fa4d9d10904fb9d5b1fa1d
     * 
     * @param unicodeSurrogates
     * @param sep
     * @return codePoint
     */
    private static String toCodePoint(String unicodeSurrogates)
    {
        ArrayList<String> r = new ArrayList<String>();
        int c = 0, p = 0, i = 0;
        while (i < unicodeSurrogates.length())
        {
            c = unicodeSurrogates.charAt(i++);
            if (p != 0)
            {
                r.add(Integer.toString((0x10000 + ((p - 0xD800) << 10) + (c - 0xDC00)), 16));
                p = 0;
            }
            else if (0xD800 <= c && c <= 0xDBFF)
            {
                p = c;
            }
            else
            {
                r.add(Integer.toString(c, 16));
            }
        }

        String output = "";
        for (String str : r)
        {
            output += (output.isEmpty() ? "" : "-") + str;
        }

        return output;
    }

    public String getCensoredReason()
    {
        return censoredReason;
    }

    public void setCensoredReason(String censoredReason)
    {
        this.censoredReason = censoredReason;
    }

    public boolean isCensored()
    {
        return censored;
    }

    public void setCensored(boolean censored, boolean isCensorshipEnabled)
    {
        // Only end the draw cursor if the message is both censored AND if censorship is actually enabled,
        // otherwise the message will insta-draw because it would be censored if censorship were enabled
        if (censored && isCensorshipEnabled)
        {
            setCompletelyDrawn();
        }
        this.censored = censored;
    }

    public void setPurged(boolean purged)
    {
        this.purged = purged;
    }

    public void resetCensorship(boolean overrideManual)
    {
        if (!purged && (!manualCensorship || overrideManual))
        {
            manualCensorship = false;
            censored = false;
            censoredReason = null;
        }
    }

    public boolean isManualCensorship()
    {
        return manualCensorship;
    }

    public void setManualCensorship(boolean manualCensorship)
    {
        this.manualCensorship = manualCensorship;
        this.censoredReason = "MANUAL";
    }

    public int getUserPostCount()
    {
        return privmsg.getPostCount();
    }

    public TwitchPrivmsg getPrivmsg()
    {
        return privmsg;
    }

    /**
     * @return age of the message since it was completely drawn, or zero if it isn't yet completed in seconds
     */
    public long getAge(long currentTime)
    {
        return completedTime == UNCOMPLETED_TIME ? 0L : (currentTime - completedTime) / 1000L;
    }

    private static String applyCasing(String str, MessageCasing casing)
    {
        if (casing == null)
        {
            return null;
        }

        switch (casing)
        {
        case LOWERCASE:
            return str.toLowerCase();
        case UPPERCASE:
            return str.toUpperCase();
        case MIXED_CASE:
        default:
            return str;
        }
    }

    /**
     * Convert the string to a list of SpriteCharacterKeys by codepoint.
     *
     * @param str
     * @returns A List of SpriteCharacterKeys
     */
    private static List<SpriteCharacterKey> toSpriteArray(String str)
    {
        // TODO java 1.8: We could use CharSequence and call codePoints method
        List<SpriteCharacterKey> keyList = new ArrayList<SpriteCharacterKey>();

        int i = 0;
        while (i < str.length())
        {
            final int codepoint = str.codePointAt(i);
            keyList.add(new SpriteCharacterKey(codepoint));
            i += Character.charCount(codepoint);
        }

        return keyList;
    }

    private String getFormattedUsername(ConfigMessage messageConfig)
    {
        final String usernameFormat = messageConfig.getUsernameFormat();
        final String casedUsername = applyCasing(rawUsername, messageConfig.getMessageCasing());
        final String formattedUsername = usernameFormat.replaceAll(ConfigMessage.USERNAME_REPLACE, casedUsername);
        return formattedUsername;
    }
} 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 ConfigColor extends Config
{
    private static final Logger logger = Logger.getLogger(ConfigColor.class);

    private Color bgColor;

    private Color fgColor;

    private Color borderColor;

    private Color highlight;

    private Color chromaColor;

    private List<Color> palette;

    private Boolean colorUsername;

    private Boolean colorTimestamp;

    private Boolean colorMessage;

    private Boolean colorJoin;

    private Boolean useTwitchColors;

    @Override
    public void reset()
    {
        this.bgColor = null;
        this.fgColor = null;
        this.borderColor = null;
        this.highlight = null;
        this.chromaColor = null;
        this.palette = null;
        this.colorUsername = null;
        this.colorTimestamp = null;
        this.colorMessage = null;
        this.colorJoin = null;
        this.useTwitchColors = null;
    }

    private void validateStrings(LoadConfigReport report, String palStr, String userBool, String timeBool, String msgBool, String joinBool, String twitchBool)
    {
        evaluateColorString(props, FontificatorProperties.KEY_COLOR_BG, report);
        evaluateColorString(props, FontificatorProperties.KEY_COLOR_FG, report);
        evaluateColorString(props, FontificatorProperties.KEY_COLOR_BORDER, report);
        evaluateColorString(props, FontificatorProperties.KEY_COLOR_HIGHLIGHT, report);
        evaluateColorString(props, FontificatorProperties.KEY_COLOR_CHROMA_KEY, report);

        validateBooleanStrings(report, userBool, timeBool, msgBool, joinBool, twitchBool);

        // An empty palette is allowed
        if (!palStr.trim().isEmpty())
        {
            String[] palColStrs = palStr.split(",");
            for (int i = 0; i < palColStrs.length; i++)
            {
                evaluateColorString(palColStrs[i], report);
            }
        }
    }

    @Override
    public LoadConfigReport load(Properties props, LoadConfigReport report)
    {
        logger.trace("Loading config color via raw properties object");

        this.props = props;

        reset();

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

        if (report.isErrorFree())
        {
            final String paletteStr = props.getProperty(FontificatorProperties.KEY_COLOR_PALETTE);

            final String userBool = props.getProperty(FontificatorProperties.KEY_COLOR_USERNAME);
            final String timeBool = props.getProperty(FontificatorProperties.KEY_COLOR_TIMESTAMP);
            final String msgBool = props.getProperty(FontificatorProperties.KEY_COLOR_MESSAGE);
            final String joinBool = props.getProperty(FontificatorProperties.KEY_COLOR_JOIN);
            final String twitchBool = props.getProperty(FontificatorProperties.KEY_COLOR_TWITCH);

            // Check that the values are valid
            validateStrings(report, paletteStr, userBool, timeBool, msgBool, joinBool, twitchBool);

            // Fill the values
            if (report.isErrorFree())
            {
                bgColor = evaluateColorString(props, FontificatorProperties.KEY_COLOR_BG, report);
                fgColor = evaluateColorString(props, FontificatorProperties.KEY_COLOR_FG, report);
                borderColor = evaluateColorString(props, FontificatorProperties.KEY_COLOR_BORDER, report);
                highlight = evaluateColorString(props, FontificatorProperties.KEY_COLOR_HIGHLIGHT, report);
                chromaColor = evaluateColorString(props, FontificatorProperties.KEY_COLOR_CHROMA_KEY, report);

                palette = new ArrayList<Color>();
                String[] palColStrs = paletteStr.isEmpty() ? new String[] {} : paletteStr.split(",");
                for (int i = 0; i < palColStrs.length; i++)
                {
                    Color palAddition = evaluateColorString(palColStrs[i], report);
                    if (palAddition != null)
                    {
                        palette.add(palAddition);
                    }
                }

                colorUsername = evaluateBooleanString(props, FontificatorProperties.KEY_COLOR_USERNAME, report);
                colorTimestamp = evaluateBooleanString(props, FontificatorProperties.KEY_COLOR_TIMESTAMP, report);
                colorMessage = evaluateBooleanString(props, FontificatorProperties.KEY_COLOR_MESSAGE, report);
                colorJoin = evaluateBooleanString(props, FontificatorProperties.KEY_COLOR_JOIN, report);
                useTwitchColors = evaluateBooleanString(props, FontificatorProperties.KEY_COLOR_TWITCH, report);
            }
        }

        return report;
    }

    public Color getBgColor()
    {
        return bgColor;
    }

    public void setBgColor(Color bgColor)
    {
        this.bgColor = bgColor;
        props.setProperty(FontificatorProperties.KEY_COLOR_BG, getColorHex(bgColor));
    }

    public Color getFgColor()
    {
        return fgColor;
    }

    public void setFgColor(Color fgColor)
    {
        this.fgColor = fgColor;
        props.setProperty(FontificatorProperties.KEY_COLOR_FG, getColorHex(fgColor));
    }

    public Color getBorderColor()
    {
        return borderColor;
    }

    public void setBorderColor(Color borderColor)
    {
        this.borderColor = borderColor;
        props.setProperty(FontificatorProperties.KEY_COLOR_BORDER, getColorHex(borderColor));
    }

    public Color getHighlight()
    {
        return highlight;
    }

    public void setHighlight(Color highlight)
    {
        this.highlight = highlight;
        props.setProperty(FontificatorProperties.KEY_COLOR_HIGHLIGHT, getColorHex(highlight));
    }

    public Color getChromaColor()
    {
        return chromaColor;
    }

    public void setChromaColor(Color chromaColor)
    {
        this.chromaColor = chromaColor;
        props.setProperty(FontificatorProperties.KEY_COLOR_CHROMA_KEY, getColorHex(chromaColor));
    }

    public List<Color> getPalette()
    {
        return palette == null ? new ArrayList<Color>() : palette;
    }

    public void setPalette(List<Color> palette)
    {
        this.palette = palette;
        String paletteString = "";
        for (int i = 0; i < palette.size(); i++)
        {
            paletteString += (i == 0 ? "" : ",") + getColorHex(palette.get(i));
        }
        props.setProperty(FontificatorProperties.KEY_COLOR_PALETTE, paletteString);
    }

    public boolean isColorUsername()
    {
        return colorUsername;
    }

    public void setColorUsername(Boolean colorUsername)
    {
        this.colorUsername = colorUsername;
        props.setProperty(FontificatorProperties.KEY_COLOR_USERNAME, Boolean.toString(colorUsername));
    }

    public boolean isColorTimestamp()
    {
        return colorTimestamp;
    }

    public void setColorTimestamp(Boolean colorTimestamp)
    {
        this.colorTimestamp = colorTimestamp;
        props.setProperty(FontificatorProperties.KEY_COLOR_TIMESTAMP, Boolean.toString(colorTimestamp));
    }

    public boolean isColorMessage()
    {
        return colorMessage;
    }

    public void setColorMessage(Boolean colorMessage)
    {
        this.colorMessage = colorMessage;
        props.setProperty(FontificatorProperties.KEY_COLOR_MESSAGE, Boolean.toString(colorMessage));
    }

    public boolean isColorJoin()
    {
        return colorJoin;
    }

    public void setColorJoin(Boolean colorJoin)
    {
        this.colorJoin = colorJoin;
        props.setProperty(FontificatorProperties.KEY_COLOR_JOIN, Boolean.toString(colorJoin));
    }

    public boolean isUseTwitchColors()
    {
        return useTwitchColors;
    }

    public void setUseTwitchColors(Boolean useTwitchColors)
    {
        this.useTwitchColors = useTwitchColors;
        props.setProperty(FontificatorProperties.KEY_COLOR_TWITCH, Boolean.toString(useTwitchColors));
    }

    public static String getColorHex(Color c)
    {
        return String.format("%06X", (0xFFFFFF & c.getRGB()));
    }
} public class ConfigEmoji extends Config
{
    private static final Logger logger = Logger.getLogger(ConfigEmoji.class);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    public ConfigEmoji()
    {
        resetWorkCompleted();
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    public int getBadgeHeightOffset()
    {
        return badgeHeightOffset;
    }

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

    public Integer getEmojiScale()
    {
        return emojiScale;
    }

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

    public Integer getBadgeScale()
    {
        return badgeScale;
    }

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

    public EmojiLoadingDisplayStragegy getDisplayStrategy()
    {
        return displayStrategy;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        reset();

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

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

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

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

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

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

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

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

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

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

        return report;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    public String getBttvLoadedChannel()
    {
        return bttvLoadedChannel;
    }

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

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

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

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

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

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

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

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

    public boolean isTwitterEnabled()
    {
        return twitterEnabled;
    }

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

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

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

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

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

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

} public class ConfigFont extends Config
{
    public static final String INTERNAL_FILE_PREFIX = "preset://";
    public static final String INTERNAL_FONT_DIR = "fonts/";
    public static final String INTERNAL_BORDER_DIR = "borders/";

    /**
     * The size of a step in the scale of a font or border on the sliders in ControlPanelFont
     */
    public static final float FONT_BORDER_SCALE_GRANULARITY = 0.25f;

    public static final float MIN_FONT_SCALE = 0.25f;
    public static final int MAX_FONT_SCALE = (int) (8 / FONT_BORDER_SCALE_GRANULARITY);

    public static final int MIN_BORDER_SCALE = 0;
    public static final int MAX_BORDER_SCALE = (int) (8 / FONT_BORDER_SCALE_GRANULARITY);

    public static final int MIN_BORDER_INSET = -256;
    public static final int MAX_BORDER_INSET = 256;

    public static final int MIN_SPACE_WIDTH = 0;
    public static final int MAX_SPACE_WIDTH = 250;

    public static final int MIN_BASELINE_OFFSET = -32;
    public static final int MAX_BASELINE_OFFSET = 64;

    public static final int MIN_LINE_SPACING = -16;
    public static final int MAX_LINE_SPACING = 32;

    public static final int MIN_CHAR_SPACING = -16;
    public static final int MAX_CHAR_SPACING = 32;

    public static final int MIN_MESSAGE_SPACING = 0;
    public static final int MAX_MESSAGE_SPACING = 128;

    private String fontFilename;

    private String borderFilename;

    private int gridWidth;

    private int gridHeight;

    private float fontScale;

    private float borderScale;

    private int borderInsetX;

    private int borderInsetY;

    private int spaceWidth;

    private int baselineOffset;

    private String characterKey;

    private char unknownChar;

    private Boolean extendedCharEnabled;

    private int lineSpacing;

    private int charSpacing;

    private int messageSpacing;

    private FontType fontType;

    /**
     * Default constructor for creating an empty config to fill with load method
     */
    public ConfigFont()
    {
    }

    @Override
    public void reset()
    {
        fontFilename = null;
        borderFilename = null;
        fontType = null;
        spaceWidth = 0;
        baselineOffset = 0;
        gridWidth = 0;
        gridHeight = 0;
        fontScale = 1.0f;
        borderScale = 1.0f;
        borderInsetX = 0;
        borderInsetY = 0;
        characterKey = null;
        unknownChar = '\0';
        extendedCharEnabled = null;
        lineSpacing = 0;
        charSpacing = 0;
        messageSpacing = 0;
    }

    public void validateFontFile(LoadConfigReport report, String fontFilename)
    {
        if (fontFilename.startsWith(INTERNAL_FILE_PREFIX))
        {
            final String plainFilename = fontFilename.substring(INTERNAL_FILE_PREFIX.length());
            if (getClass().getClassLoader().getResource(plainFilename) == null)
            {
                report.addError("Preset font " + plainFilename + " not found", LoadConfigErrorType.FILE_NOT_FOUND);
            }
        }
        else if (!new File(fontFilename).exists())
        {
            report.addError("Unable to find font PNG file \"" + fontFilename + "\"", LoadConfigErrorType.FILE_NOT_FOUND);
        }
    }

    public void validateBorderFile(LoadConfigReport report, String borderFilename)
    {
        if (borderFilename.startsWith(INTERNAL_FILE_PREFIX))
        {
            final String plainFilename = borderFilename.substring(INTERNAL_FILE_PREFIX.length());
            if (getClass().getClassLoader().getResource(plainFilename) == null)
            {
                report.addError("Preset border " + plainFilename + " not found", LoadConfigErrorType.FILE_NOT_FOUND);
            }
        }
        else if (!new File(borderFilename).exists())
        {
            report.addError("Unable to find border PNG file \"" + borderFilename + "\"", LoadConfigErrorType.FILE_NOT_FOUND);
        }
    }

    public void validateStrings(LoadConfigReport report, String widthStr, String heightStr, String charKey, String unknownCharStr)
    {
        if (unknownCharStr.length() != 1)
        {
            report.addError("Unknown character value must be a single character", LoadConfigErrorType.PARSE_ERROR_CHAR);
        }

        validateIntegerWithLimitString(FontificatorProperties.KEY_FONT_GRID_WIDTH, widthStr, 1, report);
        validateIntegerWithLimitString(FontificatorProperties.KEY_FONT_GRID_HEIGHT, heightStr, 1, report);

        if (report.isErrorFree())
        {
            int w = Integer.parseInt(widthStr);
            int h = Integer.parseInt(heightStr);
            if (w > 0 && w > 0)
            {
                if (w * h != charKey.length())
                {
                    report.addError("Character key length (" + charKey.length() + ") must match the number of characters in the font image (" + w + " x " + h + " = " + (w * h) + ")", LoadConfigErrorType.VALUE_OUT_OF_RANGE);
                }
            }

            if (!charKey.contains(unknownCharStr))
            {
                report.addError("The value for " + FontificatorProperties.KEY_FONT_UNKNOWN_CHAR + " (" + unknownCharStr + ") must also be in the value for " + FontificatorProperties.KEY_FONT_CHARACTERS, LoadConfigErrorType.VALUE_OUT_OF_RANGE);
            }
        }
    }

    public void validateStrings(LoadConfigReport report, String fontFilenameStr, String borderFilenameStr, String widthStr, String heightStr, String charKey, String unknownCharStr, String extendedCharStr, String scaleStr, String borderScaleStr, String borderInsetXStr, String borderInsetYStr, String spaceWidthStr, String baselineStr, String lineStr, String charStr, String msgStr, String fontTypeStr)
    {
        validateStrings(report, widthStr, heightStr, charKey, unknownCharStr);

        if (fontFilenameStr.isEmpty())
        {
            report.addError("A font filename is required", LoadConfigErrorType.MISSING_VALUE);
        }

        if (borderFilenameStr.isEmpty())
        {
            report.addError("A border filename is required", LoadConfigErrorType.MISSING_VALUE);
        }

        validateBooleanStrings(report, extendedCharStr);

        validateFloatWithLimitString(FontificatorProperties.KEY_FONT_SCALE, scaleStr, MIN_FONT_SCALE, MAX_FONT_SCALE, report);
        validateFloatWithLimitString(FontificatorProperties.KEY_FONT_BORDER_SCALE, borderScaleStr, MIN_BORDER_SCALE, MAX_BORDER_SCALE, report);
        validateIntegerWithLimitString(FontificatorProperties.KEY_FONT_BORDER_INSET_X, borderInsetXStr, MIN_BORDER_INSET, MAX_BORDER_INSET, report);
        validateIntegerWithLimitString(FontificatorProperties.KEY_FONT_BORDER_INSET_Y, borderInsetYStr, MIN_BORDER_INSET, MAX_BORDER_INSET, report);
        validateIntegerWithLimitString(FontificatorProperties.KEY_FONT_SPACE_WIDTH, spaceWidthStr, MIN_SPACE_WIDTH, MAX_SPACE_WIDTH, report);
        validateIntegerWithLimitString(FontificatorProperties.KEY_FONT_BASELINE_OFFSET, baselineStr, MIN_BASELINE_OFFSET, MAX_BASELINE_OFFSET, report);
        validateIntegerWithLimitString(FontificatorProperties.KEY_FONT_SPACING_LINE, lineStr, MIN_LINE_SPACING, MAX_LINE_SPACING, report);
        validateIntegerWithLimitString(FontificatorProperties.KEY_FONT_SPACING_CHAR, charStr, MIN_CHAR_SPACING, MAX_CHAR_SPACING, report);
        validateIntegerWithLimitString(FontificatorProperties.KEY_FONT_SPACING_MESSAGE, msgStr, MIN_MESSAGE_SPACING, MAX_MESSAGE_SPACING, report);

        if (!FontType.contains(fontTypeStr))
        {
            report.addError("Value of key \"" + FontificatorProperties.KEY_FONT_TYPE + "\" is invalid.", LoadConfigErrorType.PARSE_ERROR_ENUM);
        }
    }

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

        reset();

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

        if (report.isErrorFree())
        {
            final String gridWidthStr = props.getProperty(FontificatorProperties.KEY_FONT_GRID_WIDTH);
            final String gridHeightStr = props.getProperty(FontificatorProperties.KEY_FONT_GRID_HEIGHT);
            final String scaleStr = props.getProperty(FontificatorProperties.KEY_FONT_SCALE);
            final String borderScaleStr = props.getProperty(FontificatorProperties.KEY_FONT_BORDER_SCALE);
            final String borderInsetXStr = props.getProperty(FontificatorProperties.KEY_FONT_BORDER_INSET_X);
            final String borderInsetYStr = props.getProperty(FontificatorProperties.KEY_FONT_BORDER_INSET_Y);
            final String spaceWidthStr = props.getProperty(FontificatorProperties.KEY_FONT_SPACE_WIDTH);
            final String baselineStr = props.getProperty(FontificatorProperties.KEY_FONT_BASELINE_OFFSET);
            final String unknownCharStr = props.getProperty(FontificatorProperties.KEY_FONT_UNKNOWN_CHAR);
            final String extendedCharStr = props.getProperty(FontificatorProperties.KEY_FONT_EXTENDED_CHAR);
            final String lineSpacingStr = props.getProperty(FontificatorProperties.KEY_FONT_SPACING_LINE);
            final String charSpacingStr = props.getProperty(FontificatorProperties.KEY_FONT_SPACING_CHAR);
            final String messageSpacingStr = props.getProperty(FontificatorProperties.KEY_FONT_SPACING_MESSAGE);
            final String fontTypeStr = props.getProperty(FontificatorProperties.KEY_FONT_TYPE);
            final String charKeyStr = props.getProperty(FontificatorProperties.KEY_FONT_CHARACTERS);

            final String borderFilenameStr = props.getProperty(FontificatorProperties.KEY_FONT_FILE_BORDER);
            final String fontFilenameStr = props.getProperty(FontificatorProperties.KEY_FONT_FILE_FONT);

            // Check that the values are valid
            validateStrings(report, fontFilenameStr, borderFilenameStr, gridWidthStr, gridHeightStr, charKeyStr, unknownCharStr, extendedCharStr, scaleStr, borderScaleStr, borderInsetXStr, borderInsetYStr, spaceWidthStr, baselineStr, lineSpacingStr, charSpacingStr, messageSpacingStr, fontTypeStr);

            // Fill the values
            if (report.isErrorFree())
            {
                this.borderFilename = props.getProperty(FontificatorProperties.KEY_FONT_FILE_BORDER);
                this.fontFilename = props.getProperty(FontificatorProperties.KEY_FONT_FILE_FONT);
                this.fontType = FontType.valueOf(props.getProperty(FontificatorProperties.KEY_FONT_TYPE));
                this.unknownChar = unknownCharStr.charAt(0);
                this.extendedCharEnabled = evaluateBooleanString(props, FontificatorProperties.KEY_FONT_EXTENDED_CHAR, report);
                this.characterKey = props.getProperty(FontificatorProperties.KEY_FONT_CHARACTERS);
                this.gridWidth = Integer.parseInt(gridWidthStr);
                this.gridHeight = Integer.parseInt(gridHeightStr);
                this.fontScale = Float.parseFloat(scaleStr);
                this.borderScale = Float.parseFloat(borderScaleStr);
                this.borderInsetX = Integer.parseInt(borderInsetXStr);
                this.borderInsetY = Integer.parseInt(borderInsetYStr);
                this.spaceWidth = Integer.parseInt(spaceWidthStr);
                this.baselineOffset = Integer.parseInt(baselineStr);
                this.lineSpacing = Integer.parseInt(lineSpacingStr);
                this.charSpacing = Integer.parseInt(charSpacingStr);
                this.messageSpacing = Integer.parseInt(messageSpacingStr);
            }
        }

        return report;
    }

    public String getFontFilename()
    {
        return fontFilename;
    }

    public void setFontFilename(String fontFilename)
    {
        this.fontFilename = fontFilename;
        props.setProperty(FontificatorProperties.KEY_FONT_FILE_FONT, fontFilename);
    }

    public String getBorderFilename()
    {
        return borderFilename;
    }

    public void setBorderFilename(String borderFilename)
    {
        this.borderFilename = borderFilename;
        props.setProperty(FontificatorProperties.KEY_FONT_FILE_BORDER, borderFilename);
    }

    public int getGridWidth()
    {
        return gridWidth;
    }

    public void setGridWidth(int gridWidth)
    {
        this.gridWidth = gridWidth;
        props.setProperty(FontificatorProperties.KEY_FONT_GRID_WIDTH, Integer.toString(gridWidth));
    }

    public int getGridHeight()
    {
        return gridHeight;
    }

    public void setGridHeight(int gridHeight)
    {
        this.gridHeight = gridHeight;
        props.setProperty(FontificatorProperties.KEY_FONT_GRID_HEIGHT, Integer.toString(gridHeight));
    }

    public float getFontScale()
    {
        return fontScale;
    }

    public void setFontScale(float fontScale)
    {
        this.fontScale = fontScale;
        props.setProperty(FontificatorProperties.KEY_FONT_SCALE, Float.toString(fontScale));
    }

    public float getBorderScale()
    {
        return borderScale;
    }

    public void setBorderScale(float borderScale)
    {
        this.borderScale = borderScale;
        props.setProperty(FontificatorProperties.KEY_FONT_BORDER_SCALE, Float.toString(borderScale));
    }

    public int getBorderInsetX()
    {
        return borderInsetX;
    }

    public void setBorderInsetX(int borderInsetX)
    {
        this.borderInsetX = borderInsetX;
        props.setProperty(FontificatorProperties.KEY_FONT_BORDER_INSET_X, Integer.toString(borderInsetX));
    }

    public int getBorderInsetY()
    {
        return borderInsetY;
    }

    public void setBorderInsetY(int borderInsetY)
    {
        this.borderInsetY = borderInsetY;
        props.setProperty(FontificatorProperties.KEY_FONT_BORDER_INSET_Y, Integer.toString(borderInsetY));
    }

    public int getSpaceWidth()
    {
        return spaceWidth;
    }

    public void setSpaceWidth(int spaceWidth)
    {
        this.spaceWidth = spaceWidth;
        props.setProperty(FontificatorProperties.KEY_FONT_SPACE_WIDTH, Integer.toString(spaceWidth));
    }

    public int getBaselineOffset()
    {
        return baselineOffset;
    }

    public void setBaselineOffset(int baselineOffset)
    {
        this.baselineOffset = baselineOffset;
        props.getProperty(FontificatorProperties.KEY_FONT_BASELINE_OFFSET, Integer.toString(baselineOffset));
    }

    public String getCharacterKey()
    {
        return characterKey;
    }

    public void setCharacterKey(String characterKey)
    {
        this.characterKey = characterKey;
        props.setProperty(FontificatorProperties.KEY_FONT_CHARACTERS, characterKey);
    }

    public char getUnknownChar()
    {
        return unknownChar;
    }

    public void setUnknownChar(char unknownChar)
    {
        this.unknownChar = unknownChar;
        props.setProperty(FontificatorProperties.KEY_FONT_UNKNOWN_CHAR, Character.toString(unknownChar));
    }

    public boolean isExtendedCharEnabled()
    {
        return extendedCharEnabled;
    }

    public void setExtendedCharEnabled(boolean extendedCharEnabled)
    {
        this.extendedCharEnabled = extendedCharEnabled;
        props.setProperty(FontificatorProperties.KEY_FONT_EXTENDED_CHAR, Boolean.toString(extendedCharEnabled));
    }

    public int getLineSpacing()
    {
        return lineSpacing;
    }

    public void setLineSpacing(int lineSpacing)
    {
        this.lineSpacing = lineSpacing;
        props.setProperty(FontificatorProperties.KEY_FONT_SPACING_LINE, Integer.toString(lineSpacing));
    }

    public int getCharSpacing()
    {
        return charSpacing;
    }

    public void setCharSpacing(int charSpacing)
    {
        this.charSpacing = charSpacing;
        props.setProperty(FontificatorProperties.KEY_FONT_SPACING_CHAR, Integer.toString(charSpacing));
    }

    public int getMessageSpacing()
    {
        return messageSpacing;
    }

    public void setMessageSpacing(int messageSpacing)
    {
        this.messageSpacing = messageSpacing;
        props.setProperty(FontificatorProperties.KEY_FONT_SPACING_MESSAGE, Integer.toString(messageSpacing));
    }

    public FontType getFontType()
    {
        return fontType;
    }

    public void setFontType(FontType fontType)
    {
        this.fontType = fontType;
        props.setProperty(FontificatorProperties.KEY_FONT_TYPE, fontType.name());
    }

} public class ConfigMessage extends Config
{
    /**
     * This is the shortest a delay should be for updating the message. 67 ms translates to approximately 15 updates per
     * second
     */
    public static final long SHORTEST_DELAY = 67L;

    public static final int MIN_QUEUE_SIZE = 1;
    public static final int MAX_QUEUE_SIZE = 5000;

    public static final int MIN_MESSAGE_SPEED = 1;
    public static final int MAX_MESSAGE_SPEED = 121;

    public static final int MIN_MESSAGE_EXPIRATION = 0;
    public static final int MAX_MESSAGE_EXPIRATION = 720;

    public static final String USERNAME_REPLACE = "%user%";
    public static final String DEFAULT_CONTENT_BREAKER = ": ";

    private static final long MIN_MESSAGE_DELAY = 1L;

    /**
     * Whether to display join messages
     */
    private Boolean joinMessages;

    /**
     * Whether to show the username of the message poster
     */
    private Boolean usernames;

    /**
     * Whether to show the timestamps of the message
     */
    private Boolean timestamps;

    /**
     * The pattern for the username
     */
    private String usernameFormat;

    /**
     * The datetime pattern for the timestamps
     */
    private String timeFormat;

    /**
     * The text that goes between the timestamp and or username and the content of the message
     */
    private String contentBreaker;

    /**
     * The formatter for timestampstimeFormatIntimeFormatInputput
     */
    private DateFormat timeFormatter;

    /**
     * The number of messages to keep to draw and scroll back through
     */
    private Integer queueSize;

    /**
     * The rate at which messages are written in characters per second
     */
    private Integer messageSpeed;

    /**
     * The age a message can reach prior to its expiration, or zero if message expiration is turned off
     */
    private Integer expirationTime;

    /**
     * Whether the border should be hidden if there are no messages to display
     */
    private Boolean hideEmptyBorder;

    /**
     * Whether the background should be hidden if there are no messages to display
     */
    private Boolean hideEmptyBackground;

    /**
     * The method for resolving the capitalization of usernames
     */
    private UsernameCaseResolutionType caseResolutionType;

    /**
     * Whether the users are permitted to specify their own name casing by typing their own username anywhere in a
     * message
     */
    private Boolean specifyCaseAllowed;

    /**
     * Whether to leave casing alone in the message, or force all alphabetic characters to uppercase or lowercase
     */
    private MessageCasing messageCasing;

    @Override
    public void reset()
    {
        this.joinMessages = null;
        this.usernames = null;
        this.timestamps = null;
        this.usernameFormat = null;
        this.timeFormat = null;
        this.contentBreaker = null;
        this.queueSize = null;
        this.messageSpeed = null;
        this.expirationTime = null;
        this.hideEmptyBorder = null;
        this.hideEmptyBackground = null;
        this.caseResolutionType = null;
        this.specifyCaseAllowed = null;
        this.messageCasing = null;
    }

    public LoadConfigReport validateTimeFormat(LoadConfigReport report, String timeFormatStr)
    {
        try
        {
            DateFormat df = new SimpleDateFormat(timeFormatStr);
            df.format(new Date());
        }
        catch (Exception e)
        {
            report.addError("The value \"" + timeFormatStr + "\" of key " + FontificatorProperties.KEY_MESSAGE_TIMEFORMAT + " could not be used to parse a date", LoadConfigErrorType.PARSE_ERROR_STRING);
        }
        return report;
    }

    public LoadConfigReport validateStrings(LoadConfigReport report, String timeFormatStr, String queueSizeStr, String messageSpeedStr, String expirationTimeStr)
    {
        validateTimeFormat(report, timeFormatStr);

        validateIntegerWithLimitString(FontificatorProperties.KEY_MESSAGE_QUEUE_SIZE, queueSizeStr, MIN_QUEUE_SIZE, MAX_QUEUE_SIZE, report);
        validateIntegerWithLimitString(FontificatorProperties.KEY_MESSAGE_SPEED, messageSpeedStr, MIN_MESSAGE_SPEED, MAX_MESSAGE_SPEED, report);
        validateIntegerWithLimitString(FontificatorProperties.KEY_MESSAGE_EXPIRATION_TIME, expirationTimeStr, MIN_MESSAGE_EXPIRATION, MAX_MESSAGE_EXPIRATION, report);

        return report;
    }

    public LoadConfigReport validateStrings(LoadConfigReport report, String userFormatStr, String timeFormatStr, String contentBreakStr, String queueSizeStr, String messageSpeedStr, String expirationTimerStr, String hideEmptyBorderBool, String hideEmptyBgBool, String caseTypeStr, String joinBool, String userBool, String timestampBool, String specifyCaseBool, String msgCasingStr)
    {
        validateStrings(report, timeFormatStr, queueSizeStr, messageSpeedStr, expirationTimerStr);

        validateBooleanStrings(report, joinBool, userBool, timestampBool, specifyCaseBool, hideEmptyBorderBool, hideEmptyBgBool);

        if (!UsernameCaseResolutionType.contains(caseTypeStr))
        {
            report.addError("Value of key \"" + FontificatorProperties.KEY_MESSAGE_CASE_TYPE + "\" is invalid.", LoadConfigErrorType.PARSE_ERROR_ENUM);
        }

        if (!MessageCasing.contains(msgCasingStr))
        {
            report.addError("Value of key \"" + FontificatorProperties.KEY_MESSAGE_CASING + "\" is invalid.", LoadConfigErrorType.PARSE_ERROR_ENUM);
        }

        return report;
    }

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

        reset();

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

        if (report.isErrorFree())
        {
            // Check that the values are valid
            final String tfString = props.getProperty(FontificatorProperties.KEY_MESSAGE_TIMEFORMAT);
            final String usrString = props.getProperty(FontificatorProperties.KEY_MESSAGE_USERFORMAT);
            final String cbString = props.getProperty(FontificatorProperties.KEY_MESSAGE_CONTENT_BREAK);
            final String quSizeStr = props.getProperty(FontificatorProperties.KEY_MESSAGE_QUEUE_SIZE);
            final String msgSpeedStr = props.getProperty(FontificatorProperties.KEY_MESSAGE_SPEED);
            final String caseTpStr = props.getProperty(FontificatorProperties.KEY_MESSAGE_CASE_TYPE);
            final String joinBool = props.getProperty(FontificatorProperties.KEY_MESSAGE_JOIN);
            final String userBool = props.getProperty(FontificatorProperties.KEY_MESSAGE_USERNAME);
            final String timestampBool = props.getProperty(FontificatorProperties.KEY_MESSAGE_TIMESTAMP);
            final String specifyCaseBool = props.getProperty(FontificatorProperties.KEY_MESSAGE_CASE_SPECIFY);
            final String msgCaseStr = props.getProperty(FontificatorProperties.KEY_MESSAGE_CASING);
            final String expTimerStr = props.getProperty(FontificatorProperties.KEY_MESSAGE_EXPIRATION_TIME);
            final String hideEmptyBorderStr = props.getProperty(FontificatorProperties.KEY_MESSAGE_HIDE_EMPTY_BORDER);
            final String hideEmptyBgStr = props.getProperty(FontificatorProperties.KEY_MESSAGE_HIDE_EMPTY_BACKGROUND);
            validateStrings(report, usrString, tfString, cbString, quSizeStr, msgSpeedStr, expTimerStr, hideEmptyBorderStr, hideEmptyBgStr, caseTpStr, joinBool, userBool, timestampBool, specifyCaseBool, msgCaseStr);

            // Fill the values
            if (report.isErrorFree())
            {
                this.joinMessages = evaluateBooleanString(props, FontificatorProperties.KEY_MESSAGE_JOIN, report);
                this.usernames = evaluateBooleanString(props, FontificatorProperties.KEY_MESSAGE_USERNAME, report);
                this.timestamps = evaluateBooleanString(props, FontificatorProperties.KEY_MESSAGE_TIMESTAMP, report);
                this.usernameFormat = usrString;
                this.timeFormat = tfString;
                this.contentBreaker = cbString;
                this.queueSize = evaluateIntegerString(props, FontificatorProperties.KEY_MESSAGE_QUEUE_SIZE, report);
                this.messageSpeed = evaluateIntegerString(props, FontificatorProperties.KEY_MESSAGE_SPEED, report);
                this.expirationTime = evaluateIntegerString(props, FontificatorProperties.KEY_MESSAGE_EXPIRATION_TIME, report);
                this.hideEmptyBorder = evaluateBooleanString(props, FontificatorProperties.KEY_MESSAGE_HIDE_EMPTY_BORDER, report);
                this.hideEmptyBackground = evaluateBooleanString(props, FontificatorProperties.KEY_MESSAGE_HIDE_EMPTY_BACKGROUND, report);
                this.caseResolutionType = UsernameCaseResolutionType.valueOf(caseTpStr);
                this.specifyCaseAllowed = evaluateBooleanString(props, FontificatorProperties.KEY_MESSAGE_CASE_SPECIFY, report);
                this.messageCasing = MessageCasing.valueOf(msgCaseStr);
            }
        }

        return report;
    }

    /**
     * Get whether to display join messages
     */
    public boolean showJoinMessages()
    {
        return joinMessages;
    }

    public void setJoinMessages(boolean joinMessages)
    {
        this.joinMessages = joinMessages;
        props.setProperty(FontificatorProperties.KEY_MESSAGE_JOIN, Boolean.toString(joinMessages));
    }

    /**
     * Get whether to show the username of the message poster
     */
    public boolean showUsernames()
    {
        return usernames;
    }

    public void setShowUsernames(boolean usernames)
    {
        this.usernames = usernames;
        props.setProperty(FontificatorProperties.KEY_MESSAGE_USERNAME, Boolean.toString(usernames));
    }

    /**
     * Get whether to show the timestamps of the message
     */
    public boolean showTimestamps()
    {
        return timestamps;
    }

    public void setShowTimestamps(boolean timestamps)
    {
        this.timestamps = timestamps;
        props.setProperty(FontificatorProperties.KEY_MESSAGE_TIMESTAMP, Boolean.toString(timestamps));
    }

    /**
     * Get the format pattern with which to display the username
     */
    public String getUsernameFormat()
    {
        return usernameFormat;
    }

    public void setUsernameFormat(String usernameFormat)
    {
        this.usernameFormat = usernameFormat;
        props.setProperty(FontificatorProperties.KEY_MESSAGE_USERFORMAT, usernameFormat);
    }

    /**
     * Get the datetime pattern for the timestamps
     */
    public String getTimeFormat()
    {
        return timeFormat;
    }

    public DateFormat getTimerFormatter()
    {
        return timeFormatter;
    }

    public void setTimeFormat(String timeFormat)
    {
        this.timeFormat = timeFormat;
        this.timeFormatter = new SimpleDateFormat(timeFormat);
        props.setProperty(FontificatorProperties.KEY_MESSAGE_TIMEFORMAT, timeFormat);
    }

    /**
     * Get the text that marks the pre-message timestamp and or username from the content of the message
     */
    public String getContentBreaker()
    {
        return contentBreaker;
    }

    public void setContentBreaker(String contentBreaker)
    {
        this.contentBreaker = contentBreaker;
        props.setProperty(FontificatorProperties.KEY_MESSAGE_CONTENT_BREAK, contentBreaker);
    }

    /**
     * Get the number of messages to keep to draw and scroll back through
     */
    public int getQueueSize()
    {
        return queueSize;
    }

    public void setQueueSize(int queueSize)
    {
        this.queueSize = queueSize;
        props.setProperty(FontificatorProperties.KEY_MESSAGE_QUEUE_SIZE, Integer.toString(queueSize));
    }

    /**
     * Get the rate at which messages are written in characters per second
     * 
     * @return messageSpeed
     */
    public int getMessageSpeed()
    {
        return messageSpeed == null ? 0 : messageSpeed;
    }

    public long getMessageDelay()
    {
        return this.messageSpeed <= 0 || this.messageSpeed >= MAX_MESSAGE_SPEED ? MIN_MESSAGE_DELAY : 1000L / this.messageSpeed;
    }

    /**
     * Set the message speed and update the message progressor timer to match the newly set speed
     * 
     * @param messageSpeed
     * @param progressor
     */
    public void setMessageSpeed(int messageSpeed, MessageProgressor progressor)
    {
        this.messageSpeed = messageSpeed;
        props.setProperty(FontificatorProperties.KEY_MESSAGE_SPEED, Integer.toString(messageSpeed));
        if (progressor != null)
        {
            progressor.refreshTimer(getMessageDelay());
        }
    }

    public Integer getExpirationTime()
    {
        return expirationTime;
    }

    public void setExpirationTime(Integer expirationTime, MessageExpirer expirer)
    {
        this.expirationTime = expirationTime;
        props.setProperty(FontificatorProperties.KEY_MESSAGE_EXPIRATION_TIME, Integer.toString(expirationTime));
        if (expirer != null)
        {
            if (expirationTime == MIN_MESSAGE_EXPIRATION)
            {
                expirer.cancelLatest();
            }
            else
            {
                expirer.startClock();
            }
        }
    }

    public boolean isMessageExpirable()
    {
        return expirationTime != null && expirationTime != 0;
    }

    public boolean isHideEmptyBorder()
    {
        return hideEmptyBorder;
    }

    public void setHideEmptyBorder(boolean hideEmptyBorder)
    {
        this.hideEmptyBorder = hideEmptyBorder;
        props.setProperty(FontificatorProperties.KEY_MESSAGE_HIDE_EMPTY_BORDER, Boolean.toString(hideEmptyBorder));
    }

    public boolean isHideEmptyBackground()
    {
        return hideEmptyBackground;
    }

    public void setHideEmptyBackground(boolean hideEmptyBackground)
    {
        this.hideEmptyBackground = hideEmptyBackground;
        props.setProperty(FontificatorProperties.KEY_MESSAGE_HIDE_EMPTY_BACKGROUND, Boolean.toString(hideEmptyBackground));
    }

    public UsernameCaseResolutionType getCaseResolutionType()
    {
        return caseResolutionType;
    }

    public void setCaseResolutionType(UsernameCaseResolutionType caseResolutionType)
    {
        this.caseResolutionType = caseResolutionType;
        props.setProperty(FontificatorProperties.KEY_MESSAGE_CASE_TYPE, caseResolutionType.name());
    }

    public Boolean isSpecifyCaseAllowed()
    {
        return specifyCaseAllowed;
    }

    public void setSpecifyCaseAllowed(Boolean specifyCaseAllowed)
    {
        this.specifyCaseAllowed = specifyCaseAllowed;
        props.setProperty(FontificatorProperties.KEY_MESSAGE_CASE_SPECIFY, Boolean.toString(specifyCaseAllowed));
    }

    public MessageCasing getMessageCasing()
    {
        return messageCasing;
    }

    public void setMessageCasing(MessageCasing messageCasing)
    {
        this.messageCasing = messageCasing;
        props.setProperty(FontificatorProperties.KEY_MESSAGE_CASING, messageCasing.name());
    }

    @Override
    public int hashCode()
    {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((joinMessages == null) ? 0 : joinMessages.hashCode());
        result = prime * result + ((usernameFormat == null) ? 0 : usernameFormat.hashCode());
        result = prime * result + ((timeFormat == null) ? 0 : timeFormat.hashCode());
        result = prime * result + ((contentBreaker == null) ? 0 : contentBreaker.hashCode());
        result = prime * result + ((timestamps == null) ? 0 : timestamps.hashCode());
        result = prime * result + ((usernames == null) ? 0 : usernames.hashCode());
        result = prime * result + ((messageCasing == null) ? 0 : messageCasing.hashCode());
        return result;
    }

    @Override
    public boolean equals(Object obj)
    {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        ConfigMessage other = (ConfigMessage) obj;
        if (messageCasing != other.messageCasing)
            return false;
        if (joinMessages == null)
        {
            if (other.joinMessages != null)
                return false;
        }
        else if (!joinMessages.equals(other.joinMessages))
            return false;
        if (usernameFormat == null)
        {
            if (other.usernameFormat != null)
                return false;
        }
        else if (!usernameFormat.equals(other.usernameFormat))
            return false;
        if (timeFormat == null)
        {
            if (other.timeFormat != null)
                return false;
        }
        else if (!timeFormat.equals(other.timeFormat))
            return false;
        if (contentBreaker == null)
        {
            if (other.contentBreaker != null)
                return false;
        }
        else if (!contentBreaker.equals(other.contentBreaker))
            return false;
        if (timestamps == null)
        {
            if (other.timestamps != null)
                return false;
        }
        else if (!timestamps.equals(other.timestamps))
            return false;
        if (usernames == null)
        {
            if (other.usernames != null)
                return false;
        }
        else if (!usernames.equals(other.usernames))
            return false;
        return true;
    }

    /**
     * Perform a deep copy of the message config, used to compare against the previous one used to generated the string
     * of characters and emojis that are stored in a Message object
     * 
     * @param copy
     */
    public void deepCopy(ConfigMessage copy)
    {
        this.joinMessages = copy.joinMessages;
        this.usernameFormat = copy.usernameFormat;
        this.timeFormat = copy.timeFormat;
        this.contentBreaker = copy.contentBreaker;
        this.timestamps = copy.timestamps;
        this.usernames = copy.usernames;
        this.messageCasing = copy.messageCasing;
    }

} public enum MessageCasing
{
    // @formatter:off
    MIXED_CASE("Mixed casing (No modification)"), 
    UPPERCASE("Uppercase (Caps Lock)"), 
    LOWERCASE("Lowercase (No capital letters)"); 
    // @formatter:on

    private final String label;

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

    public static boolean contains(String name)
    {
        for (int i = 0; i < values().length; i++)
        {
            if (values()[i].name().equals(name))
            {
                return true;
            }
        }
        return false;
    }

    public String toString()
    {
        return label;
    }
} 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 ColorButton extends JPanel
{
    private static final long serialVersionUID = 1L;

    /**
     * The text label for identifying the color button component
     */
    private JLabel label;

    /**
     * The button that displays the color
     */
    private JButton button;

    /**
     * A reference to the control panel this button sits on, so its update method to use the newly selected colors can
     * be called
     */
    private final ControlPanelBase control;

    public ColorButton(final String label, Color value, final String explanation, ControlPanelBase controlPanel)
    {
        super();
        this.control = controlPanel;
        this.button = new JButton();
        this.button.setToolTipText("");
        this.label = new JLabel(label);
        setColor(value == null ? Color.BLACK : value);
        button.setBackground(value);

        // So the button color shows up on Mac OS
        button.setOpaque(true);

        add(this.label);
        add(this.button);

        this.button.addActionListener(new ActionListener()
        {
            public void actionPerformed(ActionEvent e)
            {
                Color color = JColorChooser.showDialog(ControlWindow.me, "Select Color" + (label == null ? "" : " for " + label), getColor());
                if (color != null)
                {
                    setColor(color);
                    control.update();
                }
            }
        });
    }

    /**
     * Get the selected color
     * 
     * @return color
     */
    public Color getColor()
    {
        return button.getBackground();
    }

    /**
     * Set the selected color
     * 
     * @param value
     */
    public void setColor(Color value)
    {
        button.setBackground(value);
    }

    /**
     * Get the label text
     * 
     * @return labelText
     */
    public String getLabel()
    {
        return label.getText();
    }

} 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;

    private FontificatorProperties fProps;

    private JEditorPane aboutPane;

    // @formatter:off
    private static String ABOUT_CONTENTS = "<html><table bgcolor=#EEEEEE width=100% border=1><tr><td>" + 
        "<center><font face=\"Arial, Helvetica\"><b>Chat Game Fontificator</b> is a Twitch chat display that makes<br />" + 
        "the chat look like the text boxes from various video games.<br /><br />" + 
        "It is free, open source, and in the public domain to the furthest<br />" + 
        "extent I am permitted to forfeit my copyright over this software.<br /><br />" + 
        "Please enjoy!<br /><br />" + 
        "By Matt Yanos<br /><br />" + 
        "<a href=\"www.github.com/GlitchCog/ChatGameFontificator\">www.github.com/GlitchCog/ChatGameFontificator</a>" + 
        "</font></center>" + 
        "</td></tr></table></html>";
    // @formatter:on

    private String helpText;

    public static ControlWindow me;

    private JDialog help;

    private JFileChooser opener;

    private JFileChooser configSaver;

    private JFileChooser screenshotSaver;

    private ScreenshotOptions screenshotOptions;

    public ControlWindow(JFrame parent, FontificatorProperties fProps, LogBox logBox)
    {
        super(parent);

        BufferedReader br = null;
        try
        {

            InputStream is = getClass().getClassLoader().getResourceAsStream("help.html");
            br = new BufferedReader(new InputStreamReader(is));
            String line;
            StringBuilder helpBuilder = new StringBuilder();
            while ((line = br.readLine()) != null)
            {
                helpBuilder.append(line);
            }
            helpText = helpBuilder.toString();
        }
        catch (Exception e)
        {
            helpText = "Unable to load help file";
            logger.error(helpText, e);
            ChatWindow.popup.handleProblem(helpText);
        }
        finally
        {
            if (br != null)
            {
                try
                {
                    br.close();
                }
                catch (Exception e)
                {
                    logger.error(e.toString(), e);
                }
            }
        }

        ControlWindow.me = this;

        setTitle("Fontificator Configuration");
        setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE);

        this.fProps = fProps;

        FileFilter cgfFileFilter = new FileNameExtensionFilter("Chat Game Fontificator Configuration (*." + DEFAULT_CONFIG_FILE_EXTENSION + ")", DEFAULT_CONFIG_FILE_EXTENSION.toLowerCase());
        FileFilter pngFileFilter = new FileNameExtensionFilter("PNG Image (*." + DEFAULT_SCREENSHOT_FILE_EXTENSION + ")", DEFAULT_SCREENSHOT_FILE_EXTENSION.toLowerCase());

        this.opener = new JFileChooser();
        this.opener.setFileFilter(cgfFileFilter);

        this.configSaver = new JFileChooser();
        this.configSaver.setFileFilter(cgfFileFilter);

        this.screenshotOptions = new ScreenshotOptions();

        this.screenshotSaver = new JFileChooser();
        this.screenshotSaver.setFileFilter(pngFileFilter);
        this.screenshotSaver.setAccessory(screenshotOptions);
    }

    public void loadLastData(ChatWindow chatWindow)
    {
        this.chatWindow = chatWindow;

        ChatWindow.setupHideOnEscape(this);

        LoadConfigReport report = new LoadConfigReport();

        fProps.clear();
        try
        {
            report = fProps.loadLast();
        }
        catch (Exception e)
        {
            final String errorMsg = "Unknown error loading last config file";
            logger.error(errorMsg, e);
            report.addError(errorMsg, LoadConfigErrorType.UNKNOWN_ERROR);
        }

        if (!report.isErrorFree())
        {
            final boolean overwriteExistingValues = report.isProblem();
            if (overwriteExistingValues)
            {
                fProps.forgetLastConfigFile();
            }
            fProps.loadDefaultValues(overwriteExistingValues);
        }
    }

    /**
     * Called separately from construction.
     * 
     * @param logBox
     */
    public void build(LogBox logBox)
    {
        constructAboutPopup();

        this.messageDialog = new MessageDialog(fProps, chatWindow, this, logBox);

        this.bot = new ChatViewerBot();
        this.bot.setUsername(fProps.getIrcConfig().getUsername());

        this.controlTabs = new ControlTabs(fProps, bot, messageDialog.getCensorPanel(), logBox);
        this.controlTabs.build(chatWindow, this);

        this.bot.setChatPanel(chatWindow.getChatPanel());

        setLayout(new GridLayout(1, 1));
        add(controlTabs);

        initMenus();

        // This wasn't built before the config was loaded into the chat control
        // tab, so set it here
        setAlwaysOnTopMenu(fProps.getChatConfig().isAlwaysOnTop());
        setRememberPositionMenu(fProps.getChatConfig().isRememberPosition());
        setAntiAliasMenu(fProps.getChatConfig().isAntiAlias());

        setupHelp();

        pack();
        setMinimumSize(getSize());
        setResizable(false);
    }

    private void setupHelp()
    {
        final String helpTitle = "Chat Game Fontificator Help";

        help = new JDialog(this, true);
        help.setTitle(helpTitle);
        help.setSize(640, 480);
        help.setLayout(new GridBagLayout());

        JEditorPane helpPane = new JEditorPane();
        helpPane.setContentType("text/html");
        helpPane.setText(helpText);
        helpPane.setEditable(false);
        JScrollPane scrollHelp = new JScrollPane(helpPane, JScrollPane.VERTICAL_SCROLLBAR_ALWAYS, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);

        JButton ok = new JButton("Close");
        ok.addActionListener(new ActionListener()
        {
            public void actionPerformed(ActionEvent e)
            {
                help.setVisible(false);
            }
        });

        GridBagConstraints gbc = new GridBagConstraints();

        gbc.gridy = 0;
        gbc.weightx = 1.0;
        gbc.weighty = 0.0;
        gbc.fill = GridBagConstraints.NONE;
        gbc.anchor = GridBagConstraints.NORTH;
        help.add(new JLabel("The function of each option available in the Control Window tabs is explained below"), gbc);

        gbc.fill = GridBagConstraints.BOTH;
        gbc.anchor = GridBagConstraints.CENTER;
        gbc.weightx = 1.0;
        gbc.weighty = 1.0;
        gbc.gridy = 1;
        help.add(scrollHelp, gbc);

        gbc.gridy++;
        gbc.fill = GridBagConstraints.NONE;
        gbc.anchor = GridBagConstraints.SOUTH;
        gbc.weighty = 0.0;
        help.add(ok, gbc);

        help.setResizable(false);
    }

    /**
     * Builds the menus from the static arrays
     */
    private void initMenus()
    {
        JMenuBar menuBar = new JMenuBar();

        final String[] mainMenuText = { "File", "Presets", "View", "Message", "Help" };
        final int[] mainMnomonics = { KeyEvent.VK_F, KeyEvent.VK_P, KeyEvent.VK_V, KeyEvent.VK_M, KeyEvent.VK_H };

        JMenu[] menus = new JMenu[mainMenuText.length];

        for (int i = 0; i < mainMenuText.length; i++)
        {
            menus[i] = new JMenu(mainMenuText[i]);
            menus[i].setMnemonic(mainMnomonics[i]);
        }

        /* File Menu Item Text */
        final String strFileOpen = "Open Configuration";
        final String strFileSave = "Save Configuration";
        final String strFileRestore = "Restore Default Configuration";
        final String strScreenshot = "Screenshot";
        final String strFileExit = "Exit";
        // @formatter:off
        final MenuComponent[] fileComponents = new MenuComponent[] { 
            new MenuComponent(strFileOpen, KeyEvent.VK_O, KeyStroke.getKeyStroke(KeyEvent.VK_O, Event.CTRL_MASK)), 
            new MenuComponent(strFileSave, KeyEvent.VK_S, KeyStroke.getKeyStroke(KeyEvent.VK_S, Event.CTRL_MASK)), 
            new MenuComponent(strFileRestore, KeyEvent.VK_R, null), 
            new MenuComponent(strScreenshot, KeyEvent.VK_C, KeyStroke.getKeyStroke(KeyEvent.VK_F8, 0)), 
            new MenuComponent(strFileExit, KeyEvent.VK_X, KeyStroke.getKeyStroke(KeyEvent.VK_Q, Event.CTRL_MASK)) 
        };
        // @formatter:on

        /* View Menu Item Text */
        final String strAntiAlias = "Anti-Aliased";
        final String strViewTop = "Always On Top";
        final String strRememberPos = "Remember Chat Window Position";
        final String strViewHide = "Hide Control Window";
        final MenuComponent[] viewComponents = new MenuComponent[] { new MenuComponent(strAntiAlias, KeyEvent.VK_A, null, true), null, new MenuComponent(strViewTop, KeyEvent.VK_T, null, true), new MenuComponent(strRememberPos, KeyEvent.VK_P, null, true), new MenuComponent(strViewHide, KeyEvent.VK_H, KeyStroke.getKeyStroke(KeyEvent.VK_H, Event.CTRL_MASK)) };

        /* Message Menu Item Text */
        final String strMsgMsg = "Message Management";
        final MenuComponent[] messageComponents = new MenuComponent[] { new MenuComponent(strMsgMsg, KeyEvent.VK_M, KeyStroke.getKeyStroke(KeyEvent.VK_M, Event.CTRL_MASK)) };

        /* Help Menu Item Text */
        final String strHelpHelp = "Help";
        final String strHelpDebug = "Debug Mode";
        final String strHelpAbout = "About";
        final MenuComponent[] helpComponents = new MenuComponent[] { new MenuComponent(strHelpHelp, KeyEvent.VK_R, null), new MenuComponent(strHelpDebug, KeyEvent.VK_D, null, true), null, new MenuComponent(strHelpAbout, KeyEvent.VK_A, null) };

        /* All menu components, with a placeholder for the Presets menu */
        final MenuComponent[][] allMenuComponents = new MenuComponent[][] { fileComponents, new MenuComponent[] {}, viewComponents, messageComponents, helpComponents };

        ActionListener mal = new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                JMenuItem mi = (JMenuItem) e.getSource();
                if (strFileOpen.equals(mi.getText()))
                {
                    open();
                }
                else if (strFileSave.equals(mi.getText()))
                {
                    saveConfig();
                }
                else if (strFileRestore.equals(mi.getText()))
                {
                    restoreDefaults(true);
                    controlTabs.refreshUiFromConfig(fProps);
                }
                else if (strScreenshot.equals(mi.getText()))
                {
                    saveScreenshot();
                }
                else if (strFileExit.equals(mi.getText()))
                {
                    attemptToExit();
                }
                else if (strAntiAlias.equals(mi.getText()))
                {
                    JCheckBoxMenuItem checkBox = (JCheckBoxMenuItem) e.getSource();
                    controlTabs.setAntiAlias(checkBox.isSelected());
                    chatWindow.getChatPanel().repaint();
                }
                else if (strViewTop.equals(mi.getText()))
                {
                    JCheckBoxMenuItem checkBox = (JCheckBoxMenuItem) e.getSource();
                    ((JFrame) getParent()).setAlwaysOnTop(checkBox.isSelected());
                    controlTabs.setAlwaysOnTopConfig(checkBox.isSelected());
                }
                else if (strRememberPos.equals(mi.getText()))
                {
                    JCheckBoxMenuItem checkBox = (JCheckBoxMenuItem) e.getSource();
                    controlTabs.setRememberChatWindowPosition(checkBox.isSelected());
                    if (checkBox.isSelected())
                    {
                        final int sx = (int) chatWindow.getLocationOnScreen().getX();
                        final int sy = (int) chatWindow.getLocationOnScreen().getY();
                        fProps.getChatConfig().setChatWindowPositionX(sx);
                        fProps.getChatConfig().setChatWindowPositionY(sy);
                    }
                }
                else if (strViewHide.equals(mi.getText()))
                {
                    setVisible(false);
                }
                else if (strMsgMsg.equals(mi.getText()))
                {
                    messageDialog.showDialog();
                }
                else if (strHelpHelp.equals(mi.getText()))
                {
                    help.setVisible(true);
                }
                else if (strHelpDebug.equals(mi.getText()))
                {
                    toggleDebugTab();
                }
                else if (strHelpAbout.equals(mi.getText()))
                {
                    showAboutPane();
                }
            }
        };

        /* Set all menu items but presets */
        JMenuItem item = null;
        for (int i = 0; i < allMenuComponents.length; i++)
        {
            for (int j = 0; j < allMenuComponents[i].length; j++)
            {
                MenuComponent mc = allMenuComponents[i][j];
                if (mc == null)
                {
                    menus[i].add(new JSeparator());
                }
                else
                {
                    item = mc.checkbox ? new JCheckBoxMenuItem(mc.label) : new JMenuItem(mc.label);
                    item.addActionListener(mal);
                    item.setMnemonic(mc.mnemonic);
                    if (mc.accelerator != null)
                    {
                        item.setAccelerator(mc.accelerator);
                    }
                    menus[i].add(item);
                }
            }
            menuBar.add(menus[i]);
        }

        Map<String, List<String[]>> presetMapSubmenuToItem = AssetIndexLoader.loadPresets();

        final List<String[]> allPresets = new ArrayList<String[]>();
        for (String key : presetMapSubmenuToItem.keySet())
        {
            for (String[] value : presetMapSubmenuToItem.get(key))
            {
                allPresets.add(value);
            }
        }

        ActionListener presetListener = new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                String sourceText = ((JMenuItem) e.getSource()).getText();
                for (String[] presets : allPresets)
                {
                    if (presets[0].equals(sourceText))
                    {
                        loadPreset(presets[0], presets[1]);
                        break;
                    }
                }
            }
        };

        for (String submenuKey : presetMapSubmenuToItem.keySet())
        {
            List<String[]> submenuItems = presetMapSubmenuToItem.get(submenuKey);
            if (submenuKey != null)
            {
                JMenu submenu = new JMenu(submenuKey);
                for (String[] itemAndFilename : submenuItems)
                {
                    JMenuItem submenuItem = new JMenuItem(itemAndFilename[0]);
                    submenuItem.addActionListener(presetListener);
                    submenu.add(submenuItem);
                }
                menus[1].add(submenu);
            }
            else
            {
                for (String[] submenuRootItemAndFilename : submenuItems)
                {
                    JMenuItem submenuRootItem = new JMenuItem(submenuRootItemAndFilename[0]);
                    submenuRootItem.addActionListener(presetListener);
                    menus[1].add(submenuRootItem);
                }
            }
        }

        for (int i = 0; i < menus.length; i++)
        {
            menuBar.add(menus[i]);
        }

        setJMenuBar(menuBar); // add the whole menu bar
    }

    /**
     * Restore default values to the configuration
     * 
     * @param overrideExistingValues
     */
    private void restoreDefaults(boolean overrideExistingValues)
    {
        boolean okayToProceed = fProps.checkForUnsavedProps(this, this);

        if (okayToProceed)
        {
            int result = JOptionPane.showConfirmDialog(this, "Reset to default configuration?", "Confirm", JOptionPane.YES_NO_OPTION);
            if (result == JOptionPane.YES_OPTION)
            {
                fProps.loadDefaultValues(overrideExistingValues);
                controlTabs.refreshUiFromConfig(fProps);
                chatWindow.getChatPanel().repaint();
            }
        }
    }

    private void open()
    {
        boolean okayToProceed = fProps.checkForUnsavedProps(this, this);

        if (okayToProceed)
        {
            int result = opener.showOpenDialog(me);
            if (result == JFileChooser.APPROVE_OPTION)
            {
                try
                {
                    LoadConfigReport report = fProps.loadFile(opener.getSelectedFile());
                    if (report.isProblem())
                    {
                        throw new Exception("Configuration file open error");
                    }
                    controlTabs.refreshUiFromConfig(fProps);
                    chatWindow.getChatPanel().repaint();
                }
                catch (Exception ex)
                {
                    final String errorMsg = "Unable to open file " + (opener.getSelectedFile() == null ? "null" : opener.getSelectedFile().getName());
                    logger.error(errorMsg, ex);
                    ChatWindow.popup.handleProblem(errorMsg);
                }
            }
        }
    }

    private void loadPreset(String presetName, String presetFilename)
    {
        boolean okayToProceed = fProps.checkForUnsavedProps(this, this);
        if (okayToProceed)
        {
            try
            {
                LoadConfigReport report = fProps.loadFile(presetFilename);
                if (report.isProblem())
                {
                    logger.error("Unsuccessful call to FontificatorProperties.loadFile(String)");
                    throw new Exception();
                }
            }
            catch (Exception ex)
            {
                logger.error(ex.toString(), ex);
                ChatWindow.popup.handleProblem("Unable to load preset " + presetName + " (" + presetFilename + ")");
            }
            controlTabs.refreshUiFromConfig(fProps);
            chatWindow.getChatPanel().repaint();
        }
    }

    /**
     * Gets a file to save to from the given file chooser, including options to overwrite
     * 
     * @param chooser
     *            the chooser to use to get the file
     * @param extension
     *            the extension of the type of file being saved, or null if there is no default extension
     * @return file or null if selection is canceled
     */
    public static File getTargetSaveFile(JFileChooser chooser, String extension)
    {
        int overwrite = JOptionPane.YES_OPTION;
        // Do while overwrite is no, so it loops back around to try again if someone says they don't want to
        // overwrite an existing file, but if they select cancel it just breaks out of the loop
        do
        {
            int result = chooser.showSaveDialog(me);

            // Default to yes, so it writes even if there's no existing file
            overwrite = JOptionPane.YES_OPTION;

            if (result == JFileChooser.APPROVE_OPTION)
            {
                File saveFile = chooser.getSelectedFile();

                if (chooser.getFileFilter() instanceof FileNameExtensionFilter)
                {
                    String[] exts = ((FileNameExtensionFilter) (chooser.getFileFilter())).getExtensions();
                    boolean endsInExt = false;
                    for (String ext : exts)
                    {
                        if (saveFile.getName().toLowerCase().endsWith(ext.toLowerCase()))
                        {
                            endsInExt = true;
                            break;
                        }
                    }
                    if (extension != null && !endsInExt)
                    {
                        saveFile = new File(saveFile.getPath() + "." + extension);
                    }
                }

                if (saveFile.exists())
                {
                    overwrite = JOptionPane.showConfirmDialog(me, "File " + saveFile.getName() + " already exists. Overwrite?", "Overwrite?", JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE);
                }

                if (overwrite == JOptionPane.YES_OPTION)
                {
                    return saveFile;
                }
            }
        } while (overwrite == JOptionPane.NO_OPTION);
        return null;
    }

    /**
     * Save configuration to a file
     * 
     * @return whether the file was saved
     */
    public boolean saveConfig()
    {
        final boolean configReadyToSave = controlTabs.refreshConfigFromUi();
        File saveFile = null;
        if (configReadyToSave)
        {
            saveFile = getTargetSaveFile(configSaver, DEFAULT_CONFIG_FILE_EXTENSION);
        }
        if (saveFile != null)
        {
            try
            {
                fProps.saveFile(saveFile);
                return true;
            }
            catch (Exception ex)
            {
                logger.error("Configuration file save error", ex);
                return false;
            }
        }
        return false;
    }

    /**
     * Takes and saves a screenshot of the current chat window
     * 
     * @return whether the screenshot was saved
     */
    private boolean saveScreenshot()
    {
        // Take the screenshot before the save file chooser is shown
        ChatPanel chat = chatWindow.getChatPanel();
        BufferedImage chatImage = new BufferedImage(chat.getWidth(), chat.getHeight(), screenshotOptions.isTransparencyEnabled() ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB);
        Graphics chatGraphics = chatImage.getGraphics();
        chat.paint(chatGraphics);

        final boolean chromaEnabled = Boolean.toString(true).equalsIgnoreCase(fProps.getProperty(FontificatorProperties.KEY_CHAT_CHROMA_ENABLED));
        if (screenshotOptions.isTransparencyEnabled() && chromaEnabled)
        {
            final int chromaKey = new Color(Integer.parseInt(fProps.getProperty(FontificatorProperties.KEY_COLOR_CHROMA_KEY), 16)).getRGB();
            final int transparentPixel = new Color(0, true).getRGB();

            for (int r = 0; r < chatImage.getHeight(); r++)
            {
                for (int c = 0; c < chatImage.getWidth(); c++)
                {
                    if (chatImage.getRGB(c, r) == chromaKey)
                    {
                        chatImage.setRGB(c, r, transparentPixel);
                    }
                }
            }
        }

        File saveFile = getTargetSaveFile(screenshotSaver, DEFAULT_SCREENSHOT_FILE_EXTENSION);
        if (saveFile != null)
        {
            try
            {
                if (screenshotOptions.isMetadataEnabled())
                {
                    ImageWriter writer = ImageIO.getImageWritersByFormatName("PNG").next();
                    ImageOutputStream stream = ImageIO.createImageOutputStream(saveFile);
                    writer.setOutput(stream);

                    IIOMetadata metadata = writer.getDefaultImageMetadata(ImageTypeSpecifier.createFromRenderedImage(chatImage), writer.getDefaultWriteParam());

                    IIOMetadataNode title = generateMetadataNode("Title", "CGF Screenshot");
                    IIOMetadataNode software = generateMetadataNode("Software", "Chat Game Fontificator");
                    final String fontGameName = ControlPanelFont.getFontGameName(fProps.getProperty(FontificatorProperties.KEY_FONT_FILE_FONT));
                    final String borderGameName = ControlPanelFont.getBorderGameName(fProps.getProperty(FontificatorProperties.KEY_FONT_FILE_BORDER));
                    IIOMetadataNode description = generateMetadataNode("Description", fontGameName + " Font / " + borderGameName + " Border");

                    IIOMetadataNode text = new IIOMetadataNode("tEXt");
                    text.appendChild(title);
                    text.appendChild(software);
                    text.appendChild(description);

                    final String metadataFormatStr = "javax_imageio_png_1.0";
                    IIOMetadataNode root = new IIOMetadataNode(metadataFormatStr);
                    root.appendChild(text);
                    metadata.mergeTree(metadataFormatStr, root);

                    writer.write(metadata, new IIOImage(chatImage, null, metadata), writer.getDefaultWriteParam());

                    stream.close();
                }
                else
                {
                    ImageIO.write(chatImage, "png", saveFile);
                }

                return true;
            }
            catch (Exception e)
            {
                logger.error("Unable to save screenshot", e);
                return false;
            }
        }
        return false;
    }

    private IIOMetadataNode generateMetadataNode(String key, String value)
    {
        IIOMetadataNode node = new IIOMetadataNode("tEXtEntry");
        node.setAttribute("keyword", key);
        node.setAttribute("value", value);
        return node;
    }

    public void setAlwaysOnTopMenu(boolean alwaysOnTop)
    {
        ((JCheckBoxMenuItem) (getJMenuBar().getMenu(2).getItem(2))).setSelected(alwaysOnTop);
    }

    public void setRememberPositionMenu(boolean rememberPosition)
    {
        ((JCheckBoxMenuItem) (getJMenuBar().getMenu(2).getItem(3))).setSelected(rememberPosition);
    }

    public void setAntiAliasMenu(boolean antiAlias)
    {
        ((JCheckBoxMenuItem) (getJMenuBar().getMenu(2).getItem(0))).setSelected(antiAlias);
    }

    public void clearUsernameCases()
    {
        bot.clearUsernameCases();
    }

    public void addManualMessage(String username, String message)
    {
        bot.sendMessageToChat(MessageType.MANUAL, message, new TwitchPrivmsg(username));
    }

    public void disconnect()
    {
        bot.disconnect();
    }

    public void attemptToExit()
    {
        attemptToExit(this);
    }

    /**
     * Any program exit should call this method to do so
     */
    public void attemptToExit(Component parent)
    {
        boolean okayToProceed = fProps.checkForUnsavedProps(this, parent);
        if (okayToProceed)
        {
            disconnect();
            System.exit(0);
        }
    }

    /**
     * Construct the popup dialog containing the About message
     */
    private void constructAboutPopup()
    {
        aboutPane = new JEditorPane("text/html", ABOUT_CONTENTS);
        aboutPane.addHyperlinkListener(new HyperlinkListener()
        {
            public void hyperlinkUpdate(HyperlinkEvent e)
            {
                if (EventType.ACTIVATED.equals(e.getEventType()))
                {
                    if (Desktop.isDesktopSupported())
                    {
                        try
                        {
                            Desktop.getDesktop().browse(URI.create("https://" + e.getDescription()));
                        }
                        catch (IOException e1)
                        {
                            e1.printStackTrace();
                        }
                    }
                }
            }
        });
        aboutPane.setEditable(false);
    }

    private void toggleDebugTab()
    {
        controlTabs.toggleDebugTab();
    }

    private void showAboutPane()
    {
        JOptionPane.showMessageDialog(this, aboutPane, "About", JOptionPane.PLAIN_MESSAGE);
    }

    public MessageDialog getMessageDialog()
    {
        return messageDialog;
    }

    public void loadAfterInit()
    {
        controlTabs.setChatWindowPosition();
    }

    public ControlPanelDebug getDebugPanel()
    {
        return controlTabs.getDebugTab();
    }

} public class DropdownFont
{
    private final String fontFilename;

    private final FontType defaultType;

    public DropdownFont(String fontFilename, FontType defaultType)
    {
        this.fontFilename = fontFilename;
        this.defaultType = defaultType;
    }

    public String getFontFilename()
    {
        return ConfigFont.INTERNAL_FILE_PREFIX + ConfigFont.INTERNAL_FONT_DIR + fontFilename;
    }

    public FontType getDefaultType()
    {
        return defaultType;
    }

} public class SpriteCharacterKey
{
    /**
     * The character this represents
     */
    private int codepoint;

    /**
     * Whether the character falls outside of the inclusive ASCII range 32-127
     */
    private boolean extended;

    /**
     * The emoji this character represents
     */
    private LazyLoadEmoji emoji;

    /**
     * Background color override for emoji, for handling variable FFZ badge colors (bot is default gray, but changes to
     * green if the bot is also a moderator)
     */
    private Color emojiBgColorOverride;

    /**
     * Whether the emoji set is a badge image
     */
    private boolean badge;

    /**
     * Construct as a character
     * 
     * @param character
     */
    public SpriteCharacterKey(char character)
    {
        this(character, null, false);
    }

    public SpriteCharacterKey(int codepoint)
    {
        this(codepoint, null, false);
    }

    /**
     * Construct as an emoji
     * 
     * @param emoji
     */
    public SpriteCharacterKey(LazyLoadEmoji emoji, boolean badge)
    {
        this(127, emoji, badge);
    }

    /**
     * Private constructor to ensure that one or the other parameter is marked as unused
     * 
     * @param codepoint
     * @param emoji
     * @param badge
     *            Only used for emoji
     */
    private SpriteCharacterKey(int codepoint, LazyLoadEmoji emoji, boolean badge)
    {
        this.codepoint = codepoint;
        /* not-great: Take code point and toss surrogate pair, if present, to check if in ascii set */
        this.extended = !SpriteFont.NORMAL_ASCII_KEY.contains(Character.toString(Character.toChars(this.codepoint)[0]));
        this.emoji = emoji;
        this.badge = badge;
    }

    /**
     * Get the character as a char, assuming this represents a character instead of emoji
     * 
     * @return char
     */
    public char getChar()
    {
        // Not a pretty compromise.  Basically get the broken half a code point when trying to getChar() something that
        // isn't in the BMP.  This is mostly OK.
        // Ideally this would throw an exception if !Character.isBmpCodePoint(this.codepoint)
        return Character.toChars(this.codepoint)[0];
    }

    /**
     * Get the character as a codepoint, assuming this represents a character instead of emoji
     *
     * @return int
     */
    public int getCodepoint()
    {
        return this.codepoint;
    }

    /**
     * Get the emoji, assuming this represents an emoji
     * 
     * @return emoji
     */
    public LazyLoadEmoji getEmoji()
    {
        return emoji;
    }

    /**
     * Whether this sprite character is a character, not an emoji
     * 
     * @return isChar
     */
    public boolean isChar()
    {
        return emoji == null;
    }

    /**
     * Whether this sprite character is an emoji, not a character
     * 
     * @return isNotChar
     */
    public boolean isEmoji()
    {
        return !isChar() && !badge;
    }

    /**
     * Whether this sprite character is a badge emoji, not a character
     * 
     * @return
     */
    public boolean isBadge()
    {
        return !isChar() & badge;
    }

    @Override
    public String toString()
    {
        return isEmoji() ? "[E]" : new String(Character.toChars(this.codepoint));
    }

    /**
     * @return
     */
    public boolean isExtended()
    {
        return extended;
    }

    public Color getEmojiBgColor()
    {
        if (emojiBgColorOverride != null)
        {
            return emojiBgColorOverride;
        }

        return emoji == null ? null : emoji.getBgColor();
    }

    public void setEmojiBgColorOverride(Color emojiColorOverride)
    {
        this.emojiBgColorOverride = emojiColorOverride;
    }

} public class SpriteFont
{
    private static final Logger logger = Logger.getLogger(SpriteFont.class);

    public static final String NORMAL_ASCII_KEY = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + (char) 127;

    /**
     * The number of pixels between each individual badge and between badges and the username;
     */
    public static final int BADGE_MINIMUM_SPACING_PIXELS = 3;

    protected SpriteCache sprites;

    protected Map<Integer, Rectangle> characterBounds;

    /**
     * Characters that can be line breaks for wrapping to the next line
     */
    protected static final String WORD_BREAKS = " \n\r\t";

    /**
     * Characters that indicate a return to the start of the next line
     */
    protected static final String LINE_BREAKS = "\n";

    /**
     * Shift the messages down this many lines
     */
    protected int lineScrollOffset;

    protected ConfigFont config;

    public SpriteFont(ConfigFont config)
    {
        logger.trace("Creating sprite font using config font filename " + (config == null ? "null" : config.getFontFilename()));
        this.config = config;
        this.characterBounds = new HashMap<Integer, Rectangle>();
        this.sprites = new SpriteCache(config);
    }

    /**
     * Get the dimensions of an emoji image
     * 
     * @param img
     * @param emojiConfig
     * @return
     */
    private int[] getEmojiDimensions(SpriteCharacterKey c, ConfigEmoji emojiConfig)
    {
        LazyLoadEmoji emoji = c.getEmoji();
        Image img = emoji.getImage(emojiConfig.isAnimationEnabled());

        int iw;
        int ih;

        if (img == null)
        {
            switch (emojiConfig.getDisplayStrategy())
            {
            case SPACE:
            case BOX_FILL:
            case BOX_FRAME:
                iw = emoji.getWidth();
                ih = emoji.getHeight();
                break;
            case UNKNOWN:
                // Do not use the emoji scaling below because it's a character, not an emoji
                // We can pass a null in for the FontMetrics, because we know the unknown character falls within the
                // non-extended range
                return new int[] { getCharacterWidth(null, new SpriteCharacterKey(config.getUnknownChar()), emojiConfig), 1 };
            case NOTHING:
            default:
                iw = 0;
                ih = 1;
                break;
            }
        }
        else
        {
            iw = emoji.getWidth();
            ih = emoji.getHeight();
        }

        float h;
        float w;

        float eScale = emoji.getType().isBadge() ? (emojiConfig.getBadgeScale() / 100.0f) : (emojiConfig.getEmojiScale() / 100.0f);
        if ((emoji.getType().isBadge() && emojiConfig.isBadgeScaleToLine()) || (!emoji.getType().isBadge() && emojiConfig.isEmojiScaleToLine()))
        {
            final float emojiScaleRatio = eScale * getLineHeightScaled() / (float) ih;
            h = ih * emojiScaleRatio;
            w = iw * h / ih;
        }
        else
        {
            w = eScale * iw;
            h = eScale * ih;
        }

        return new int[] { (int) w, (int) h };
    }

    /**
     * Return how wide a character is in pixels- must take scale into consideration scale
     * 
     * @param c
     * @return character width
     */
    public int getCharacterWidth(FontMetrics fontMetrics, SpriteCharacterKey c, ConfigEmoji emojiConfig)
    {
        if (c.isChar())
        {
            int baseWidth;

            // Extended characters are enabled
            if (c.isExtended())
            {
                if (config.isExtendedCharEnabled())
                {
                    // Return string width of extended char
                    baseWidth = fontMetrics.charWidth(c.getCodepoint());
                    // Don't include scale in this calculation, because it's already built into the font size
                    return (int) (baseWidth + config.getCharSpacing() * config.getFontScale());
                }
                // The extended character should be replaced with the unknown character
                else
                {
                    baseWidth = getCharacterBounds(config.getUnknownChar()).width;
                }
            }
            // It's a normal character
            else
            {
                // Character
                baseWidth = getCharacterBounds(c.getCodepoint()).width;
            }
            return (int) ((baseWidth + config.getCharSpacing()) * config.getFontScale());
        }
        else
        {
            // Emoji
            int[] eDim = getEmojiDimensions(c, emojiConfig);
            final int charSpacing = (int) (config.getCharSpacing() * config.getFontScale());
            final int extraSpacing = (c.getEmoji().getType().isBadge() ? Math.max(charSpacing, (int) (BADGE_MINIMUM_SPACING_PIXELS * config.getFontScale())) : charSpacing);
            return eDim[0] + extraSpacing;
        }

    }

    public void calculateCharacterDimensions()
    {
        // Start from scratch
        characterBounds.clear();

        // For fixed width, just put the same sized box for all characters. The
        // only difference is the location on the sprite grid
        switch (config.getFontType())
        {
        case FIXED_WIDTH:
            calculateFixedCharacterDimensions();
            break;
        case VARIABLE_WIDTH:
            calculateVariableCharacterDimensions();
            break;
        default:
            logger.error("Unknown font type: " + config.getFontType());
            break;
        }
    }

    private void calculateFixedCharacterDimensions()
    {
        final int spriteWidth = sprites.getSprite(config).getSpriteWidth();
        final int spriteHeight = sprites.getSprite(config).getSpriteHeight();

        for (int i = 0; i < config.getCharacterKey().length(); i++)
        {
            final char c = config.getCharacterKey().charAt(i);
            final int index = config.getCharacterKey().indexOf(c);
            final int gridX = index % config.getGridWidth();
            final int gridY = index / config.getGridWidth();
            Rectangle bounds = new Rectangle();
            bounds.setLocation(gridX * spriteWidth, gridY * spriteHeight);
            bounds.setSize(spriteWidth, spriteHeight);
            characterBounds.put((int) c, bounds);
        }
    }

    /**
     * Go through each character and determine how wide it is based on the non transparent pixels
     */
    private void calculateVariableCharacterDimensions()
    {
        logger.trace("Calculating character dimensions");
        characterBounds.clear();

        Sprite sprite = sprites.getSprite(config);

        final int charWidth = sprite.getSpriteWidth();
        final int charHeight = sprite.getSpriteHeight();
        final int wholeWidth = sprite.getImage().getWidth();
        final int wholeHeight = sprite.getImage().getHeight();

        final String key = config.getCharacterKey();

        int[] pixelData = new int[wholeWidth * wholeHeight];
        pixelData = sprite.getImage().getRGB(0, 0, wholeWidth, wholeHeight, pixelData, 0, wholeWidth);
        int letterIndex = 0;
        // For each grid row
        for (int y = 0; y < wholeHeight; y += charHeight)
        {
            // For each grid col
            for (int x = 0; x < wholeWidth; x += charWidth)
            {
                final char ckey = key.charAt(letterIndex);

                boolean leftEdgeFound = false;
                int leftEdge = 0;
                int rightEdge = 0;

                // Go through each column in the letter cell
                for (int localX = 0; localX < charWidth; localX++)
                {
                    int absoluteX = localX + x;
                    // Go down the whole column looking for opaque pixels
                    boolean opaquePixelFound = false;
                    for (int absoluteY = y; absoluteY < y + charHeight && !opaquePixelFound; absoluteY++)
                    {
                        // If the alpha component of the pixel RGB integer
                        // is greater than zero, then the pixel counts
                        // towards the width of the character
                        opaquePixelFound = ((pixelData[absoluteX + absoluteY * wholeWidth] >> 24) & 0xff) > 0;
                    }

                    if (opaquePixelFound)
                    {
                        // Set left only the first time an opaque pixel is
                        // found
                        if (!leftEdgeFound)
                        {
                            leftEdge = localX;
                            leftEdgeFound = true;
                        }
                        // Keep setting the right each time to expand the
                        // width
                        rightEdge = localX;
                    }
                }

                // Add one pixel for the space between characters, and take
                // the
                // minimum of the calculated width and the max charWidth
                // just in case

                rightEdge++;

                final int letterWidth = Math.min(charWidth, rightEdge - leftEdge);

                Rectangle bounds = new Rectangle(x + leftEdge, y, letterWidth, charHeight);

                // If the character is a space and the bounds calculated to be nothing, meaning there were no
                // opaque pixels found, then make it a default quarter of the sprite width
                if (!leftEdgeFound)
                {
                    // Then just use a quarter of the character width
                    characterBounds.put((int) ckey, new Rectangle(x + charWidth / 4, y, charWidth / 2, charHeight));
                }
                // For all other characters, or for spaces that have some non transparent pixels, use the calculated
                // bounds
                else
                {
                    characterBounds.put((int) ckey, bounds);
                }

                letterIndex++;
            }
        }
    }

    /**
     * Get the bounding box for the character in the sprite font image (does not use scale at all)
     * 
     * @param c
     * @return bounding box
     */
    public Rectangle getCharacterBounds(int c)
    {
        if (!config.getCharacterKey().contains(new String(Character.toChars(c))))
        {
            c = config.getUnknownChar();
        }

        if (FontType.VARIABLE_WIDTH.equals(config.getFontType()) && c == ' ')
        {
            Rectangle spaceBounds = characterBounds.get(c);
            spaceBounds.width = (int) (sprites.getSprite(config).getSpriteWidth() * (config.getSpaceWidth() / 100.0f));
        }

        return characterBounds.get(c);
    }

    /**
     * Should be called if the ConfigFont object is updated
     */
    public void updateForConfigChange()
    {
        calculateCharacterDimensions();
    }

    /**
     * Get the distance in pixels from the top of one line of text to the top of the next line of text, scaled
     * 
     * @return lineHeightScaled
     */
    public int getLineHeightScaled()
    {
        return (int) (getLineHeight() * config.getFontScale());
    }

    /**
     * Get the distance in pixels from the top of one line of text to the top of the next line of text, unscaled
     * 
     * @return lineHeight
     */
    public int getLineHeight()
    {
        return getFontHeight() + config.getLineSpacing();
    }

    public int getFontHeight()
    {
        return sprites.getSprite(config).pixelHeight;
    }

    public int getLineScrollOffset()
    {
        return lineScrollOffset;
    }

    public void setLineScrollOffset(int lineScrollOffset)
    {
        this.lineScrollOffset = lineScrollOffset;
    }

    /**
     * Change the line scroll offset by the specified amount if it falls within or up/down to the specified min and max
     * values
     * 
     * @param delta
     *            The amount to try to change the line scroll offset
     * @param min
     *            The smallest the line scroll offset should be
     * @param max
     *            The largest the line scroll offset should be
     */
    public void incrementLineScrollOffset(int delta, int min, int max)
    {
        final int test = lineScrollOffset + delta;
        if (test >= min && test < max)
        {
            lineScrollOffset = test;
        }
        else
        {
            lineScrollOffset = Math.max(lineScrollOffset, min);
            lineScrollOffset = Math.min(lineScrollOffset, max);
        }
    }

    /**
     * Do a mock drawing of the string to determine the dimensions of the bounding box that would surround the drawn
     * message
     * 
     * @param message
     * @param messageConfig
     * @param emojiConfig
     * @param emojiManager
     * @param lineWrapLength
     * @return The size of the bounding box of the drawn message
     */
    public Dimension getMessageDimensions(Message message, FontMetrics fontMetrics, ConfigMessage messageConfig, ConfigEmoji emojiConfig, EmojiManager emojiManager, int lineWrapLength, boolean lastMessage)
    {
        return drawMessage(null, fontMetrics, message, null, null, messageConfig, emojiConfig, emojiManager, 0, 0, 0, 0, lineWrapLength, false, null, null, lastMessage);
    }

    /**
     * @param g2d
     *            The graphics object upon which to draw
     * @param fontMetrics
     *            The actual font metrics of the JPanel drawing this SpriteFont, used to draw extended characters
     * @param msg
     *            The message to draw
     * @param userColor
     *            The color unique to the sender of the message being drawn
     * @param colorConfig
     *            The configuration for how to color messages
     * @param messageConfig
     *            The configuration for how to draw messages
     * @param emojiConfig
     *            The configuration for how to handle emoji
     * @param emojiManager
     *            The manager for accessing emoji images
     * @param x_init
     *            The left edge x coordinate to start drawing from
     * @param y_init
     *            The top edge y coordinate to start drawing from (probably up in negative space above the graphics
     *            object)
     * @param topLimit
     *            When to start drawing lines as y increases, because many will be off screen or under the top order
     * @param botLimit
     *            Only draw up to this edge
     * @param lineWrapLength
     *            How long to let the text go to the right before going to a new line
     * @param debug
     *            Whether to draw debugging boxes
     * @param debugColor
     *            The color to draw debugging boxes
     * @param emojiObserver
     *            Used to update animated GIF BTTV emotes
     * @param lastMessage
     *            Whether this message is the last message, used to determine whether to add message spacing distance
     *            after the message is printed
     * @return The size of the bounding box of the drawn message
     */
    public Dimension drawMessage(Graphics2D g2d, FontMetrics fontMetrics, Message msg, Color userColor, ConfigColor colorConfig, ConfigMessage messageConfig, ConfigEmoji emojiConfig, EmojiManager emojiManager, int x_init, int y_init, int topLimit, int botLimit, int lineWrapLength, boolean debug, Color debugColor, ImageObserver emojiObserver, boolean lastMessage)
    {
        if (msg.isJoinType() && !messageConfig.showJoinMessages())
        {
            return new Dimension();
        }

        SpriteCharacterKey[] text = msg.getText(emojiManager, messageConfig, emojiConfig);

        int maxCharWidth = 0;
        for (int c = 0; c < text.length; c++)
        {
            maxCharWidth = Math.max(maxCharWidth, getCharacterWidth(fontMetrics, text[c], emojiConfig));
        }
        if (maxCharWidth > lineWrapLength)
        {
            return new Dimension();
        }

        // Because the letters are set back by this amount to divide up the
        // spacing between their left and right sides
        x_init -= config.getCharSpacing() / 2;

        int x = x_init;
        int y = y_init;

        int height = getLineHeightScaled();
        int maxWidth = 0;
        int width = 0;

        y += lineScrollOffset * height;

        boolean forcedBreak = false;

        Color color = Color.WHITE;

        // Go through each character in the text
        for (int ci = 0; ci < text.length; ci++)
        {
            if (colorConfig != null)
            {
                color = getFontColor(msg, ci, messageConfig, colorConfig, userColor);
            }

            // If the character is a line return, go to the next line
            if (LINE_BREAKS.contains(String.valueOf(text[ci].getChar())))
            {
                x = x_init;
                maxWidth = Math.max(maxWidth, width);
                width = 0;
                y += getLineHeightScaled();
                if (ci < msg.getDrawCursor())
                {
                    height += getLineHeightScaled();
                }
            }
            // If it's not a line return, look forward into the text to find if
            // the next word fits
            else if (WORD_BREAKS.contains(String.valueOf(text[ci].getChar())))
            {
                int charWidth = getCharacterWidth(fontMetrics, text[ci], emojiConfig);
                x += charWidth;
                width += charWidth;
                forcedBreak = false;
            }
            else
            {
                int currentWordPixelWidth = 0;
                for (int nwc = 0; nwc < text.length - ci && !WORD_BREAKS.contains(String.valueOf(text[ci + nwc].getChar())); nwc++)
                {
                    currentWordPixelWidth += getCharacterWidth(fontMetrics, text[ci + nwc], emojiConfig);
                }
                int distanceAlreadyFilled = x - x_init;

                // The next word fits
                if (distanceAlreadyFilled + currentWordPixelWidth < lineWrapLength)
                {
                    if (g2d != null && y >= topLimit && y < botLimit && ci < msg.getDrawCursor())
                    {
                        drawCharacter(g2d, fontMetrics, text[ci], x, y, emojiConfig, color, debug, debugColor, emojiObserver);
                    }
                    int charWidth = getCharacterWidth(fontMetrics, text[ci], emojiConfig);
                    x += charWidth;
                    width += charWidth;
                }
                // The next word doesn't fit, but it doesn't exceed the length
                // of a full line, so hit return
                else if (!forcedBreak && currentWordPixelWidth < lineWrapLength)
                {
                    x = x_init;
                    maxWidth = Math.max(maxWidth, width);
                    width = 0;
                    y += getLineHeightScaled();
                    if (ci < msg.getDrawCursor())
                    {
                        height += getLineHeightScaled();
                    }
                    if (g2d != null && y >= topLimit && y < botLimit && ci < msg.getDrawCursor())
                    {
                        drawCharacter(g2d, fontMetrics, text[ci], x, y, emojiConfig, color, debug, debugColor, emojiObserver);
                    }
                    int charWidth = getCharacterWidth(fontMetrics, text[ci], emojiConfig);
                    x += charWidth;
                    width += charWidth;
                }
                // The next word doesn't even fit on its own line, so it needs a
                // forced break at the end of the line
                else
                {
                    forcedBreak = true;
                    distanceAlreadyFilled = x - x_init;
                    final int remainderOfTheLine = lineWrapLength - distanceAlreadyFilled;
                    int charWidth = getCharacterWidth(fontMetrics, text[ci], emojiConfig);
                    if (charWidth > remainderOfTheLine)
                    {
                        x = x_init;
                        maxWidth = Math.max(maxWidth, width);
                        width = 0;
                        y += getLineHeightScaled();
                        if (ci < msg.getDrawCursor())
                        {
                            height += getLineHeightScaled();
                        }
                    }

                    if (g2d != null && y >= topLimit && y < botLimit && ci < msg.getDrawCursor())
                    {
                        drawCharacter(g2d, fontMetrics, text[ci], x, y, emojiConfig, color, debug, debugColor, emojiObserver);
                    }
                    x += charWidth;
                    width += charWidth;
                }
            }
        }

        if (!lastMessage)
        {
            height += config.getMessageSpacing();
        }

        return new Dimension(maxWidth, height);
    }

    private void drawCharacter(Graphics2D g2d, FontMetrics fontMetrics, SpriteCharacterKey sck, int x, int y, ConfigEmoji emojiConfig, Color color, boolean debug, Color debugColor, ImageObserver emojiObserver)
    {
        final int drawX = x + config.getCharSpacing() / 2;
        int drawY = y;

        if (sck.isChar())
        {
            final boolean validNormalChar = !sck.isExtended() && characterBounds.containsKey(sck.getCodepoint());
            final boolean drawUnknownChar = !validNormalChar && !config.isExtendedCharEnabled();

            // If the option to draw the unknown character in place of anything out of range is enabled,
            // then switch out the SpriteCharacterKey with one given the selected unknown character
            if (drawUnknownChar)
            {
                sck = new SpriteCharacterKey(config.getUnknownChar());
            }

            // Draw either a valid normal ASCII character, or draw the selected unknown replacement character
            if (validNormalChar || drawUnknownChar)
            {
                Rectangle bounds = characterBounds.get(sck.getCodepoint());
                sprites.getSprite(config).draw(g2d, drawX, drawY, bounds.width, bounds.height, bounds, config.getFontScale(), color);
                if (debug)
                {
                    g2d.setColor(debugColor);
                    g2d.drawRect(drawX, drawY, (int) (bounds.width * config.getFontScale()), (int) (bounds.height * config.getFontScale()));
                }
            }
            // The character is invalid, and drawing the unknown char is not selected, so draw the extended characters
            else
            {
                g2d.setColor(color);
                g2d.drawString(sck.toString(), drawX, drawY + (fontMetrics.getHeight() - fontMetrics.getDescent()) - config.getBaselineOffset() * config.getFontScale());
            }
        }
        else
        {
            int[] eDim = getEmojiDimensions(sck, emojiConfig);
            // yOffset is to center the emoji on the line
            int yOffset = (int) (sprites.getSprite(config).getSpriteDrawHeight(config.getFontScale()) / 2 - config.getBaselineOffset() * config.getFontScale()) - (sck.isBadge() ? emojiConfig.getBadgeHeightOffset() : 0);
            drawY += yOffset - eDim[1] / 2;
            Image eImage = sck.getEmoji().getImage(emojiConfig.isAnimationEnabled());
            if (eImage == null)
            {
                // If the image is null, then it's not loaded, so do the backup display strategy
                g2d.setColor(color);
                switch (emojiConfig.getDisplayStrategy())
                {
                case BOX_FILL:
                    g2d.fillRect(drawX, drawY, eDim[0] + 1, eDim[1] + 1);
                    break;
                case BOX_FRAME:
                    g2d.drawRect(drawX, drawY, eDim[0], eDim[1]);
                    break;
                case UNKNOWN:
                    drawCharacter(g2d, fontMetrics, new SpriteCharacterKey(config.getUnknownChar()), x, y, emojiConfig, color, debug, debugColor, emojiObserver);
                    break;
                case SPACE:
                case NOTHING:
                default:
                    break;
                }
            }
            else
            {
                // Draw a color square background for the emoji (for FrankerFaceZ badges)
                if (sck.getEmoji().isColoringRequired())
                {
                    g2d.setColor(sck.getEmojiBgColor());
                    g2d.fillRect(drawX, drawY, eDim[0] + 1, eDim[1] + 1);
                }
                // Draw the emoji image
                g2d.drawImage(eImage, drawX, drawY, eDim[0], eDim[1], emojiObserver);
            }
        }
    }

    /**
     * If the change to the next character requires a change to the color of the text, this method will set the
     * appropriate color
     * 
     * @param msg
     * @param c
     * @param messageConfig
     * @param colorConfig
     * @param userColor
     */
    public Color getFontColor(Message msg, int c, ConfigMessage messageConfig, ConfigColor colorConfig, Color userColor)
    {
        boolean timestampIndexEncountered = messageConfig.showTimestamps() && c < msg.getIndexTimestamp(messageConfig);

        if (msg.isJoinType())
        {
            if (timestampIndexEncountered)
            {
                return colorConfig.isColorJoin() && colorConfig.isColorTimestamp() ? colorConfig.getHighlight() : colorConfig.getFgColor();
            }
            else
            {
                return colorConfig.isColorJoin() ? colorConfig.getHighlight() : colorConfig.getFgColor();
            }
        }
        else
        {
            if (timestampIndexEncountered)
            {
                return colorConfig.isColorTimestamp() ? userColor : colorConfig.getFgColor();
            }
            else if (messageConfig.showUsernames() && c >= msg.getIndexUsername(messageConfig)[0] && c < msg.getIndexUsername(messageConfig)[1])
            {
                return colorConfig.isColorUsername() ? userColor : colorConfig.getFgColor();
            }
            else if (colorConfig.isColorMessage() || MessageType.ACTION.equals(msg.getType()))
            {
                return userColor;
            }
            else
            {
                return colorConfig.getFgColor();
            }
        }
    }

}
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.GridBagConstraints;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.Collection;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.imageio.ImageIO;
import javax.swing.JButton;
import javax.swing.JFileChooser;
import javax.swing.JPanel;
import javax.swing.JTextField;
import javax.swing.filechooser.FileFilter;
import javax.swing.filechooser.FileNameExtensionFilter;
import org.apache.log4j.Logger;
import com.glitchcog.fontificator.bot.Message;
import com.glitchcog.fontificator.bot.MessageType;
import com.glitchcog.fontificator.config.ConfigColor;
import com.glitchcog.fontificator.config.ConfigEmoji;
import com.glitchcog.fontificator.config.ConfigFont;
import com.glitchcog.fontificator.config.ConfigMessage;
import com.glitchcog.fontificator.config.FontType;
import com.glitchcog.fontificator.config.MessageCasing;
import com.glitchcog.fontificator.config.UsernameCaseResolutionType;
import com.glitchcog.fontificator.config.loadreport.LoadConfigReport;
import com.glitchcog.fontificator.gui.component.ColorButton;
import com.glitchcog.fontificator.gui.controls.ControlWindow;
import com.glitchcog.fontificator.gui.controls.panel.model.DropdownFont;
import com.glitchcog.fontificator.sprite.SpriteCharacterKey;
import com.glitchcog.fontificator.sprite.SpriteFont;
        "Sphinx of black quartz, judge my vow.", 
        "Waltz, nymph, for quick jigs vex Bud.", 
        "How vexingly quick daft zebras jump!", 
        "Bright vixens jump; dozy fowl quack."
    };
    // @formatter:on

    public ExamplePanel(ControlPanelBase basePanel)
    {
        build(basePanel);
    }

    private JTextField lineFormatInput;

    private ColorButton fontColorButton;

    private ColorButton bgColorButton;

    private static final String TEXT_FONTNAME = "[FONTNAME]";

    private static final String TEXT_PANGRAM = "[PANGRAM]";

    private static final String TEXT_ASCII = "[ASCII]";

    private void build(ControlPanelBase basePanel)
    {
        fontColorButton = new ColorButton("Font Tint", Color.WHITE, "Color to tint the font in the example image", basePanel);
        bgColorButton = new ColorButton("Backgroud", Color.DARK_GRAY.darker(), "Color to make the background", basePanel);
        lineFormatInput = new JTextField(TEXT_FONTNAME + " " + TEXT_PANGRAM + " " + TEXT_ASCII);
        JButton saveExample = new JButton("Generate/Save Example");
        saveExample.addActionListener(new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                generateExampleImage();
            }
        });

        GridBagConstraints gbc = ControlPanelBase.getGbc();
        gbc.fill = GridBagConstraints.NONE;
        gbc.weightx = 0.0;
        add(fontColorButton, gbc);
        gbc.gridx++;
        add(bgColorButton, gbc);
        gbc.fill = GridBagConstraints.HORIZONTAL;
        gbc.weightx = 0.9;
        gbc.gridx++;
        add(lineFormatInput, gbc);
        gbc.fill = GridBagConstraints.NONE;
        gbc.weightx = 0.1;
        gbc.gridx++;
        add(saveExample, gbc);
    }

    private static Message getExampleMessage(final String fontFilename, final String format)
    {
        String messageText = format;
        messageText = messageText.replaceAll(Pattern.quote(TEXT_FONTNAME), Matcher.quoteReplacement(ControlPanelFont.getFontGameName(fontFilename)));
        messageText = messageText.replaceAll(Pattern.quote(TEXT_PANGRAM), Matcher.quoteReplacement(PANGRAMS[Math.abs(fontFilename.hashCode()) % PANGRAMS.length]));
        messageText = messageText.replaceAll(Pattern.quote(TEXT_ASCII), Matcher.quoteReplacement(SpriteFont.NORMAL_ASCII_KEY));
        return new Message(MessageType.MANUAL, "", messageText, null);
    }

    private void generateExampleImage()
    {
        Properties emptyProps = new Properties();
        LoadConfigReport ignoreReport = new LoadConfigReport();

        ConfigFont fontConfig = new ConfigFont();
        fontConfig.load(emptyProps, ignoreReport);

        fontConfig.setBorderFilename(ConfigFont.INTERNAL_FILE_PREFIX + "borders/dw3_border.png");
        fontConfig.setFontFilename(ConfigFont.INTERNAL_FILE_PREFIX + "fonts/dw3_font.png");
        fontConfig.setFontType(FontType.FIXED_WIDTH);
        fontConfig.setUnknownChar((char) 127);
        fontConfig.setExtendedCharEnabled(false);
        fontConfig.setCharacterKey(SpriteFont.NORMAL_ASCII_KEY);
        fontConfig.setGridWidth(8);
        fontConfig.setGridHeight(12);
        fontConfig.setFontScale(1);
        fontConfig.setBorderScale(0);
        fontConfig.setBorderInsetX(0);
        fontConfig.setBorderInsetY(0);
        fontConfig.setSpaceWidth(25);
        fontConfig.setBaselineOffset(0);
        final int heightOffset = 6;
        fontConfig.setLineSpacing(heightOffset * 2);
        fontConfig.setCharSpacing(0);

        ConfigColor colorConfig = new ConfigColor();
        colorConfig.load(emptyProps, ignoreReport);

        colorConfig.setBgColor(Color.WHITE);
        colorConfig.setFgColor(Color.WHITE);
        colorConfig.setBorderColor(Color.WHITE);
        colorConfig.setHighlight(Color.GREEN);
        colorConfig.setChromaColor(Color.GREEN);
        colorConfig.setColorUsername(false);
        colorConfig.setColorTimestamp(false);
        colorConfig.setColorMessage(false);
        colorConfig.setColorJoin(false);
        colorConfig.setUseTwitchColors(true);

        ConfigMessage messageConfig = new ConfigMessage();
        messageConfig.load(emptyProps, ignoreReport);

        messageConfig.setJoinMessages(false);
        messageConfig.setShowUsernames(false);
        messageConfig.setShowTimestamps(false);
        messageConfig.setTimeFormat("[HH:mm:ss]");
        messageConfig.setQueueSize(Integer.MAX_VALUE);
        messageConfig.setMessageSpeed(Integer.MAX_VALUE, null);
        messageConfig.setExpirationTime(Integer.MAX_VALUE, null);
        messageConfig.setHideEmptyBorder(false);
        messageConfig.setHideEmptyBackground(false);
        messageConfig.setCaseResolutionType(UsernameCaseResolutionType.NONE);
        messageConfig.setSpecifyCaseAllowed(false);
        messageConfig.setMessageCasing(MessageCasing.MIXED_CASE);

        ConfigEmoji emojiConfig = new ConfigEmoji();