/**
 * GmailAssistant 1.1 (2008-03-16)
 * Copyright 2008 Zach Scrivena
 * zachscrivena@gmail.com
 * http://gmailassistant.sourceforge.net/
 *
 * Notifier for multiple Gmail accounts.
 *
 * TERMS AND CONDITIONS:
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 2,
 * as published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package gmailassistant;

import com.sun.mail.imap.IMAPFolder;
import gmailassistant.Account;
import java.awt.Color;
import java.awt.Image;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Queue;
import java.util.Timer;
import java.util.TimerTask;
import java.util.regex.Pattern;
import javax.mail.AuthenticationFailedException;
import javax.mail.Flags;
import javax.mail.Folder;
import javax.mail.Message;
import javax.mail.Session;
import javax.mail.Store;
import javax.mail.search.FlagTerm;
import javax.mail.search.SearchTerm;
import javax.swing.AbstractAction;
import javax.swing.JColorChooser;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JTextField;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import javax.swing.event.DocumentEvent;


/**
 * Represent a Gmail account.
 */
class Account
        extends JFrame
        implements Comparable<Account>
{
    /** map: key string ---> property */
    static final Map<String,Property> KEY_STRING_TO_PROPERTY = new HashMap<String,Property>();

    /** suffix for Gmail username */
    private static final String GMAIL_USERNAME_SUFFIX = "@gmail.com";

    /** regex pattern for Gmail username, excludig suffix "@gmail.com" */
    private static final Pattern GMAIL_USERNAME_PATTERN = Pattern.compile(
            "[" + Pattern.quote("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.") + "]+");

    /** regex pattern for Google Apps email username, including domain name */
    private static final Pattern GOOGLE_APPS_EMAIL_USERNAME_PATTERN = Pattern.compile(
            "[" + Pattern.quote("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-.") + "]+" +
            "@[^@\\s]+\\.[^@\\.\\s]+");

    /** incoming mail protocol (secure IMAP) */
    private static final String INCOMING_PROTOCOL = "imaps";

    /** incoming mail server */
    private static final String INCOMING_SERVER = "imap.gmail.com";

    /** incoming mail server port */
    private static final int INCOMING_PORT = 993;

    /** timer refresh interval */
    private static final long TIMER_REFRESH_INTERVAL_MILLISECONDS = 200L;

    /** search term for messages with the SEEN keep turned OFF */
    private final SearchTerm unseenFlag = new FlagTerm(new Flags(Flags.Flag.SEEN), false);

    /** parent GmailAssistant object */
    private final GmailAssistant parent;

    /** account ID */
    final int id;

    /** text fields for the notify labels */
    private final JTextField[] notifyFields;

    /** property keys corresponding to the notify labels */
    private final Property[] notifyLabelsProperty;

    /** is this account being edited? */
    private volatile boolean editing = false;

    /** have the login credentials been verified? */
    private volatile boolean loginCredentialsVerified = false;

    /** is the login username valid? */
    private volatile boolean usernameValid = false;

    /** is the login password valid? */
    private volatile boolean passwordValid = false;

    /** is the notify selection valid? */
    private volatile boolean notifyValid = false;

    /** mail store for the account */
    private final Store store;

    /** mutex lock for this.store */
    private final Object storeLock = new Object();

    /** unread mails for this account */
    private final Map<MailId,Mail> mails = new HashMap<MailId,Mail>();

    /** last received mail ID */
    private int lastSequenceNumber = 0;

    /** timer for the mail checker */
    private final Timer timer;

    /** queued actions to be performed by the timer */
    private final Queue<ActionType> actions = new ArrayDeque<ActionType>();

    /** account properties */
    private final Map<Property,Object> properties;


    /**
    * Static initialization block.
    */
    static
    {
        /* create map: key string ---> property */
        for (Property p : Property.values())
        {
            if (p.keyString != null)
            {
                Account.KEY_STRING_TO_PROPERTY.put(p.keyString, p);
            }
        }
    }


    /**
    * Return a new default account properties map.
    *
    * @return
    *      new default account properties
    */
    static Map<Property,Object> getNewDefaultProperties()
    {
        final Map<Property,Object> properties = new HashMap<Property,Object>();

        for (Property p : Property.values())
        {
            properties.put(p, p.initialVal);
        }

        return properties;
    }


    /**
    * Constructor.
    *
    * @param parent
    *      parent GmailAssistant object
    * @param id
    *      account ID assigned by the parent GmailAssistant object
    * @param properties
    *      account properties for the new account
    */
    Account(
            final GmailAssistant parent,
            final int id,
            final Map<Property,Object> properties)
    {
        /*********************
        * INITIALIZE FIELDS *
        *********************/

        this.parent = parent;
        this.id = id;
        this.properties = properties;

        synchronized (this.storeLock)
        {
            final Session session = Session.getInstance(System.getProperties(), null);
            session.setDebug(false);

            try
            {
                this.store = session.getStore(Account.INCOMING_PROTOCOL);
            }
            catch (Exception e)
            {
                SwingManipulator.showErrorDialog(this.parent, GmailAssistant.NAME,
                        "(INTERNAL) Invalid incoming mail protocol \"" + Account.INCOMING_PROTOCOL + "\".");

                throw new TerminatingException(e.getMessage());
            }
        }

        /******************************
        * INITIALIZE FORM COMPONENTS *
        ******************************/

        initComponents();

        /*****************************
        * CONFIGURE FORM COMPONENTS *
        *****************************/

        addWindowListener(new WindowAdapter()
        {
            @Override
            public void windowClosing(WindowEvent e)
            {
                /* equivalent to clicking on the "Cancel" button */
                Account.this.cancelButton.doClick();
            }

            @Override
            public void windowIconified(WindowEvent e)
            {
                /* equivalent to clicking on the "Cancel" button */
                Account.this.cancelButton.doClick();
            }
        });

        /* inherit "always on top" behavior of parent */
        try
        {
            setAlwaysOnTop(this.parent.isAlwaysOnTop());
        }
        catch (Exception e)
        {
            /* ignore */
        }

        /* inherit program icon of parent */
        final List<Image> icons = this.parent.getIconImages();

        if (!icons.isEmpty())
        {
            setIconImage(icons.get(0));
        }

        /* field: "username" */
        this.usernameField.setText((String) getProperty(Property.USERNAME));
        this.usernameField.getDocument().addDocumentListener(new DocumentListenerAdapter()
        {
            @Override
            public void insertUpdate(DocumentEvent e)
            {
                Account.this.loginCredentialsVerified = false;
                setProperty(Property.USERNAME, SwingManipulator.getTextJTextField(Account.this.usernameField));
                checkUsername();
            }

            @Override
            public void removeUpdate(DocumentEvent e)
            {
                Account.this.loginCredentialsVerified = false;
                setProperty(Property.USERNAME, SwingManipulator.getTextJTextField(Account.this.usernameField));
                checkUsername();
            }

            @Override
            public void changedUpdate(DocumentEvent e)
            {
                Account.this.loginCredentialsVerified = false;
                setProperty(Property.USERNAME, SwingManipulator.getTextJTextField(Account.this.usernameField));
                checkUsername();
            }
        });

        /* field: "password" */
        this.passwordField.setText(String.valueOf((char[]) getProperty(Property.PASSWORD)));
        this.passwordField.getDocument().addDocumentListener(new DocumentListenerAdapter()
        {
            @Override
            public void insertUpdate(DocumentEvent e)
            {
                Account.this.loginCredentialsVerified = false;
                setProperty(Property.PASSWORD, SwingManipulator.getPasswordJPasswordField(Account.this.passwordField));
                checkPassword();
            }

            @Override
            public void removeUpdate(DocumentEvent e)
            {
                Account.this.loginCredentialsVerified = false;
                setProperty(Property.PASSWORD, SwingManipulator.getPasswordJPasswordField(Account.this.passwordField));
                checkPassword();
            }

            @Override
            public void changedUpdate(DocumentEvent e)
            {
                Account.this.loginCredentialsVerified = false;
                setProperty(Property.PASSWORD, SwingManipulator.getPasswordJPasswordField(Account.this.passwordField));
                checkPassword();
            }
        });

        /* button: "color" */
        final Color c = (Color) getProperty(Property.COLOR);
        final String s = "rgb(" +
                c.getRed() + "," +
                c.getGreen() + "," +
                c.getBlue() + ")";

        this.colorButton.setText("<html><span style='color:" + s +
                ";background-color:" + s +
                "'>&nbsp;M&nbsp;</span></html>");

        this.colorButton.addActionListener(new ActionListener()
        {
            public void actionPerformed(ActionEvent e)
            {
                final Color c = JColorChooser.showDialog(
                    Account.this,
                    "Account Color - " + Account.this.getTitle(),
                    (Color) getProperty(Property.COLOR));

                if (c != null)
                {
                    final String s = "rgb(" +
                            c.getRed() + "," +
                            c.getGreen() + "," +
                            c.getBlue() + ")";

                    Account.this.colorButton.setText("<html><span style='color:" + s +
                            ";background-color:" + s +
                            "'>&nbsp;M&nbsp;</span></html>");
                    setProperty(Property.COLOR, c);
                }
            }
        });

        /* label: "lock" */
        this.lock.setToolTipText(GmailAssistant.NAME + " accesses your Gmail account securely using IMAP over SSL");

        /* radio button: "Notify Inbox" */
        this.notifyInboxRadio.setSelected((Boolean) getProperty(Property.NOTIFY_INBOX));
        this.notifyInboxRadio.addActionListener(new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                setProperty(Property.NOTIFY_INBOX, Account.this.notifyInboxRadio.isSelected());
                checkNotify();
            }
        });

        /* radio button: "Notify Any" */
        this.notifyAnyRadio.setSelected((Boolean) getProperty(Property.NOTIFY_ANY));
        this.notifyAnyRadio.addActionListener(new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                setProperty(Property.NOTIFY_ANY, Account.this.notifyAnyRadio.isSelected());
                checkNotify();
            }
        });

        /* radio button: "Notify Labels" */
        this.notifyLabelsRadio.setSelected((Boolean) getProperty(Property.NOTIFY_LABELS));
        this.notifyLabelsRadio.addActionListener(new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                setProperty(Property.NOTIFY_LABELS, Account.this.notifyLabelsRadio.isSelected());
                checkNotify();
            }
        });

        /* list of notify labels */
        this.notifyFields = new JTextField[]
        {
            this.notifyField1,
            this.notifyField2,
            this.notifyField3,
            this.notifyField4,
            this.notifyField5,
            this.notifyField6
        };

        this.notifyLabelsProperty = new Property[]
        {
            Property.NOTIFY_LABEL1,
            Property.NOTIFY_LABEL2,
            Property.NOTIFY_LABEL3,
            Property.NOTIFY_LABEL4,
            Property.NOTIFY_LABEL5,
            Property.NOTIFY_LABEL6
        };

        for (int i = 0; i < this.notifyFields.length; i++)
        {
            final JTextField f = this.notifyFields[i];
            final Property p = notifyLabelsProperty[i];

            f.setText((String) getProperty(p));

            f.getDocument().addDocumentListener(new DocumentListenerAdapter()
            {
                @Override
                public void insertUpdate(DocumentEvent e)
                {
                    setProperty(p, f.getText().trim());
                    checkNotify();
                }

                @Override
                public void removeUpdate(DocumentEvent e)
                {
                    setProperty(p, f.getText().trim());
                    checkNotify();
                }

                @Override
                public void changedUpdate(DocumentEvent e)
                {
                    setProperty(p, f.getText().trim());
                    checkNotify();
                }
            });
        }

        /* check box: "popup" */
        this.popupBox.setSelected((Boolean) getProperty(Property.ALERT_POPUP));
        this.popupBox.addActionListener(new ActionListener()
        {
            public void actionPerformed(ActionEvent e)
            {
                setProperty(Property.ALERT_POPUP, Account.this.popupBox.isSelected());
            }
        });

        /* check box: "chime" */
        this.chimeBox.setSelected((Boolean) getProperty(Property.ALERT_CHIME));
        this.chimeBox.addActionListener(new ActionListener()
        {
            public void actionPerformed(ActionEvent e)
            {
                setProperty(Property.ALERT_CHIME, Account.this.chimeBox.isSelected());
            }
        });

        /* check box: "bell" */
        this.bellBox.setSelected((Boolean) getProperty(Property.ALERT_PERIODIC_BELL));
        this.bellBox.addActionListener(new ActionListener()
        {
            public void actionPerformed(ActionEvent e)
            {
                setProperty(Property.ALERT_PERIODIC_BELL, Account.this.bellBox.isSelected());
            }
        });

        /* check boxe: "blink" */
        this.blinkBox.setSelected((Boolean) getProperty(Property.ALERT_LED));
        this.blinkBox.addActionListener(new ActionListener()
        {
            public void actionPerformed(ActionEvent e)
            {
                setProperty(Property.ALERT_LED, Account.this.blinkBox.isSelected());
            }
        });

        /* button: "Test Alerts" */
        this.testButton.addActionListener(new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                testAlerts();
            }
        });

        /* button: "OK" */
        this.okButton.addActionListener(new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                tryEnableAccount();
            }
        });

        /* button: "Cancel" */
        this.cancelButton.addActionListener(new ActionListener()
        {
            @Override
            public void actionPerformed(ActionEvent e)
            {
                tryDisableAccount();
            }
        });

        /* add standard editing popup menu to text fields */
        SwingManipulator.addStandardEditingPopupMenu(new JTextField[]
        {
            this.usernameField,
            this.passwordField,
            this.notifyField1,
            this.notifyField2,
            this.notifyField3,
            this.notifyField4,
            this.notifyField5,
            this.notifyField6
        });

        /* key binding: ENTER key */
        this.scrollPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
                .put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), "ENTER_OK_BUTTON");

        this.scrollPane.getActionMap().put("ENTER_OK_BUTTON", new AbstractAction()
        {
            public void actionPerformed(ActionEvent e)
            {
                Account.this.okButton.doClick();
            }
        });

        /* key binding: ESCAPE key */
        this.scrollPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
                .put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "ESCAPE_CANCEL_BUTTON");

        this.scrollPane.getActionMap().put("ESCAPE_CANCEL_BUTTON", new AbstractAction()
        {
            public void actionPerformed(ActionEvent e)
            {
                Account.this.cancelButton.doClick();
            }
        });

        /* check user selections on the form */
        checkUsername();
        checkPassword();
        checkNotify();

        /* center form on the parent form */
        setLocationRelativeTo(this.parent);

        /***************************
        * INITIALIZE TIMER THREAD *
        ***************************/

        this.timer = new Timer("Mail-Checker-Timer", true);

        this.timer.schedule(new TimerTask()
        {
            /**
            * Check this account for unread mails.
            */
            @Override
            public void run()
            {
                ActionType action;

                synchronized (Account.this.actions)
                {
                    action = Account.this.actions.poll();
                }

                if (action == ActionType.ENABLE)
                {
                    /* enable the mail checker */
                    setProperty(Property.ENABLED, true);
                    setProperty(Property.STATUS, "<html>Waiting for next mail check</html>");
                }
                else if (action == ActionType.DISABLE)
                {
                    /* disable the mail checker */
                    setProperty(Property.ENABLED, false);
                    setProperty(Property.STATUS, "<html><font color='red'>Disabled</font></html>");
                }

                /* proceed to check mail now? */
                boolean proceedToCheckNow = false;

                if ((Boolean) getProperty(Property.ENABLED))
                {
                    if (action == ActionType.CHECKNOW)
                    {
                        proceedToCheckNow = true;
                    }
                    else
                    {
                        final long mailCheckIntervalMilliseconds =
                                (Long) Account.this.parent.getProperty(GmailAssistant.Property.MAIL_CHECK_INTERVAL_MILLISECONDS);

                        if ((Boolean) getProperty(Property.ERROR))
                        {
                            if ((System.currentTimeMillis() - (Long) getProperty(Property.LAST_CHECKED)) >=
                                    (mailCheckIntervalMilliseconds / 10))
                            {
                                proceedToCheckNow = true;
                            }
                        }
                        else
                        {
                            if ((System.currentTimeMillis() - (Long) getProperty(Property.LAST_CHECKED)) >=
                                    mailCheckIntervalMilliseconds)
                            {
                                proceedToCheckNow = true;
                            }
                        }
                    }
                }

                if (proceedToCheckNow)
                {
                    /* proceed to check for unread mails now */
                    setProperty(Property.STATUS, "<html>Checking mail</html>");

                    if (sleepTillInterrupted(0L) == ActionType.CANCEL)
                    {
                        return;
                    }

                    /* get IMAP folders corresponding to the Gmail labels */
                    final List<MailLabel> labels = new ArrayList<MailLabel>();
                    final boolean notifyInbox = (Boolean) getProperty(Property.NOTIFY_INBOX);
                    final boolean notifyAny = (Boolean) getProperty(Property.NOTIFY_ANY);
                    final boolean notifyLabels = (Boolean) getProperty(Property.NOTIFY_LABELS);

                    if (notifyInbox)
                    {
                        /* notify on any unread mail in Inbox */
                        labels.add(new MailLabel("INBOX"));
                    }
                    else if (notifyAny)
                    {
                        /* notify on any unread mail */
                        labels.add(new MailLabel("All Mail"));
                    }
                    else if (notifyLabels)
                    {
                        /* notify on unread mail with the specified labels */
                        for (Property p : Account.this.notifyLabelsProperty)
                        {
                            final String s = (String) getProperty(p);

                            if (!s.isEmpty())
                            {
                                labels.add(new MailLabel(s));
                            }
                        }
                    }

                    synchronized (Account.this.storeLock)
                    {
                        setProperty(Property.STATUS, "<html>Checking mail: Logging in</html>");

                        if (sleepTillInterrupted(0L) == ActionType.CANCEL)
                        {
                            return;
                        }

                        /* connect to mail server */
                        String username = (String) getProperty(Property.USERNAME);

                        if (!username.contains("@"))
                        {
                            /* treat as Gmail account */
                            username += Account.GMAIL_USERNAME_SUFFIX;
                        }

                        try
                        {
                            Account.this.store.connect(
                                    Account.INCOMING_SERVER,
                                    Account.INCOMING_PORT,
                                    username,
                                    (String) getProperty(Property.PASSWORD_STRING));
                        }
                        catch (IllegalStateException e)
                        {
                            /* ignore */
                        }
                        catch (Exception e)
                        {
                            setProperty(Property.STATUS, "<html><font color='red'>Failed to connect to the Gmail server; waiting to retry</font></html>");
                            setProperty(Property.ERROR, true);
                            setProperty(Property.LAST_CHECKED, System.currentTimeMillis());
                            return;
                        }

                        if (sleepTillInterrupted(0L) == ActionType.CANCEL)
                        {
                            return;
                        }

                        boolean recent = false;

                        /* check for unread mails for each label */
                        synchronized (Account.this.mails)
                        {
                            /* set "keep" flag of all existing unread mails to false */
                            for (Mail m : Account.this.mails.values())
                            {
                                m.keep = false;
                            }

                            /* check mails for existing labels */
                            for (MailLabel l : labels)
                            {
                                if (sleepTillInterrupted(0L) == ActionType.CANCEL)
                                {
                                    return;
                                }

                                /* fetch unread mails for this label from the server */
                                if (notifyInbox)
                                {
                                    setProperty(Property.STATUS, "<html>Checking mail: Fetching unread mails in Inbox</html>");
                                }
                                else if (notifyAny)
                                {
                                    setProperty(Property.STATUS, "<html>Checking mail: Fetching unread mails</html>");
                                }
                                else if (notifyLabels)
                                {
                                    setProperty(Property.STATUS, "<html>Checking mail: Fetching unread \"" + l.label + "\" mails</html>");
                                }

                                try
                                {
                                    final IMAPFolder f = (IMAPFolder) Account.this.store.getFolder(l.folder);

                                    if (sleepTillInterrupted(0L) == ActionType.CANCEL)
                                    {
                                        return;
                                    }

                                    if (f.exists())
                                    {
                                        if (!f.isOpen())
                                        {
                                            f.open(Folder.READ_ONLY);
                                        }

                                        if (sleepTillInterrupted(0L) == ActionType.CANCEL)