package com.fsck.k9.mail.store.imap;


import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import android.text.TextUtils;

import com.fsck.k9.mail.Body;
import com.fsck.k9.mail.BodyFactory;
import com.fsck.k9.mail.FetchProfile;
import com.fsck.k9.mail.Flag;
import com.fsck.k9.mail.Folder;
import com.fsck.k9.mail.K9MailLib;
import com.fsck.k9.mail.Message;
import com.fsck.k9.mail.MessageRetrievalListener;
import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.Part;
import com.fsck.k9.mail.filter.EOLConvertingOutputStream;
import com.fsck.k9.mail.internet.MimeBodyPart;
import com.fsck.k9.mail.internet.MimeHeader;
import com.fsck.k9.mail.internet.MimeMessageHelper;
import com.fsck.k9.mail.internet.MimeMultipart;
import com.fsck.k9.mail.internet.MimeUtility;
import timber.log.Timber;

import static com.fsck.k9.mail.store.imap.ImapUtility.getLastResponse;


class ImapFolder extends Folder<ImapMessage> {
    private static final ThreadLocal<SimpleDateFormat> RFC3501_DATE = new ThreadLocal<SimpleDateFormat>() {
        @Override
        protected SimpleDateFormat initialValue() {
            return new SimpleDateFormat("dd-MMM-yyyy", Locale.US);
        }
    };
    private static final int MORE_MESSAGES_WINDOW_SIZE = 500;
    private static final int FETCH_WINDOW_SIZE = 100;


    protected volatile int messageCount = -1;
    protected volatile long uidNext = -1L;
    protected volatile ImapConnection connection;
    protected ImapStore store = null;
    protected Map<Long, String> msgSeqUidMap = new ConcurrentHashMap<Long, String>();
    private final FolderNameCodec folderNameCodec;
    private final String name;
    private int mode;
    private volatile boolean exists;
    private boolean inSearch = false;
    private boolean canCreateKeywords = false;


    public ImapFolder(ImapStore store, String name) {
        this(store, name, store.getFolderNameCodec());
    }

    ImapFolder(ImapStore store, String name, FolderNameCodec folderNameCodec) {
        super();
        this.store = store;
        this.name = name;
        this.folderNameCodec = folderNameCodec;
    }

    private String getPrefixedName() throws MessagingException {
        String prefixedName = "";

        if (!store.getStoreConfig().getInboxFolderName().equalsIgnoreCase(name)) {
            ImapConnection connection;
            synchronized (this) {
                if (this.connection == null) {
                    connection = store.getConnection();
                } else {
                    connection = this.connection;
                }
            }

            try {
                connection.open();
            } catch (IOException ioe) {
                throw new MessagingException("Unable to get IMAP prefix", ioe);
            } finally {
                if (this.connection == null) {
                    store.releaseConnection(connection);
                }
            }

            prefixedName = store.getCombinedPrefix();
        }

        prefixedName += name;

        return prefixedName;
    }

    private List<ImapResponse> executeSimpleCommand(String command) throws MessagingException, IOException {
        return handleUntaggedResponses(connection.executeSimpleCommand(command));
    }

    @Override
    public void open(int mode) throws MessagingException {
        internalOpen(mode);

        if (messageCount == -1) {
            throw new MessagingException("Did not find message count during open");
        }
    }

    protected List<ImapResponse> internalOpen(int mode) throws MessagingException {
        if (isOpen() && this.mode == mode) {
            // Make sure the connection is valid. If it's not we'll close it down and continue
            // on to get a new one.
            try {
                return executeSimpleCommand(Commands.NOOP);
            } catch (IOException ioe) {
                /* don't throw */ ioExceptionHandler(connection, ioe);
            }
        }

        store.releaseConnection(connection);

        synchronized (this) {
            connection = store.getConnection();
        }

        try {
            msgSeqUidMap.clear();

            String openCommand = mode == OPEN_MODE_RW ? "SELECT" : "EXAMINE";
            String encodedFolderName = folderNameCodec.encode(getPrefixedName());
            String escapedFolderName = ImapUtility.encodeString(encodedFolderName);
            String command = String.format("%s %s", openCommand, escapedFolderName);
            List<ImapResponse> responses = executeSimpleCommand(command);

            /*
             * If the command succeeds we expect the folder has been opened read-write unless we
             * are notified otherwise in the responses.
             */
            this.mode = mode;

            for (ImapResponse response : responses) {
                handlePermanentFlags(response);
            }

            handleSelectOrExamineOkResponse(getLastResponse(responses));

            exists = true;

            return responses;
        } catch (IOException ioe) {
            throw ioExceptionHandler(connection, ioe);
        } catch (MessagingException me) {
            Timber.e(me, "Unable to open connection for %s", getLogId());
            throw me;
        }
    }

    private void handlePermanentFlags(ImapResponse response) {
        PermanentFlagsResponse permanentFlagsResponse = PermanentFlagsResponse.parse(response);
        if (permanentFlagsResponse == null) {
            return;
        }

        Set<Flag> permanentFlags = store.getPermanentFlagsIndex();
        permanentFlags.addAll(permanentFlagsResponse.getFlags());
        canCreateKeywords = permanentFlagsResponse.canCreateKeywords();
    }

    private void handleSelectOrExamineOkResponse(ImapResponse response) {
        SelectOrExamineResponse selectOrExamineResponse = SelectOrExamineResponse.parse(response);
        if (selectOrExamineResponse == null) {
            // This shouldn't happen
            return;
        }

        if (selectOrExamineResponse.hasOpenMode()) {
            mode = selectOrExamineResponse.getOpenMode();
        }
    }

    @Override
    public boolean isOpen() {
        return connection != null;
    }

    @Override
    public int getMode() {
        return mode;
    }

    @Override
    public void close() {
        messageCount = -1;

        if (!isOpen()) {
            return;
        }

        synchronized (this) {
            // If we are mid-search and we get a close request, we gotta trash the connection.
            if (inSearch && connection != null) {
                Timber.i("IMAP search was aborted, shutting down connection.");
                connection.close();
            } else {
                store.releaseConnection(connection);
            }

            connection = null;
        }
    }

    @Override
    public String getName() {
        return name;
    }

    private boolean exists(String escapedFolderName) throws MessagingException {
        try {
            // Since we don't care about RECENT, we'll use that for the check, because we're checking
            // a folder other than ourself, and don't want any untagged responses to cause a change
            // in our own fields
            connection.executeSimpleCommand(String.format("STATUS %s (RECENT)", escapedFolderName));
            return true;
        } catch (IOException ioe) {
            throw ioExceptionHandler(connection, ioe);
        } catch (NegativeImapResponseException e) {
            return false;
        }
    }

    @Override
    public boolean exists() throws MessagingException {
        if (exists) {
            return true;
        }

        /*
         * This method needs to operate in the unselected mode as well as the selected mode
         * so we must get the connection ourselves if it's not there. We are specifically
         * not calling checkOpen() since we don't care if the folder is open.
         */
        ImapConnection connection;
        synchronized (this) {
            if (this.connection == null) {
                connection = store.getConnection();
            } else {
                connection = this.connection;
            }
        }

        try {
            String encodedFolderName = folderNameCodec.encode(getPrefixedName());
            String escapedFolderName = ImapUtility.encodeString(encodedFolderName);
            connection.executeSimpleCommand(String.format("STATUS %s (UIDVALIDITY)", escapedFolderName));

            exists = true;

            return true;
        } catch (NegativeImapResponseException e) {
            return false;
        } catch (IOException ioe) {
            throw ioExceptionHandler(connection, ioe);
        } finally {
            if (this.connection == null) {
                store.releaseConnection(connection);
            }
        }
    }

    @Override
    public boolean create(FolderType type) throws MessagingException {
        /*
         * This method needs to operate in the unselected mode as well as the selected mode
         * so we must get the connection ourselves if it's not there. We are specifically
         * not calling checkOpen() since we don't care if the folder is open.
         */
        ImapConnection connection;
        synchronized (this) {
            if (this.connection == null) {
                connection = store.getConnection();
            } else {
                connection = this.connection;
            }
        }

        try {
            String encodedFolderName = folderNameCodec.encode(getPrefixedName());
            String escapedFolderName = ImapUtility.encodeString(encodedFolderName);
            connection.executeSimpleCommand(String.format("CREATE %s", escapedFolderName));

            return true;
        } catch (NegativeImapResponseException e) {
            return false;
        } catch (IOException ioe) {
            throw ioExceptionHandler(this.connection, ioe);
        } finally {
            if (this.connection == null) {
                store.releaseConnection(connection);
            }
        }
    }

    /**
     * Copies the given messages to the specified folder.
     *
     * <p>
     * <strong>Note:</strong>
     * Only the UIDs of the given {@link Message} instances are used. It is assumed that all
     * UIDs represent valid messages in this folder.
     * </p>
     *
     * @param messages
     *         The messages to copy to the specified folder.
     * @param folder
     *         The name of the target folder.
     *
     * @return The mapping of original message UIDs to the new server UIDs.
     */
    @Override
    public Map<String, String> copyMessages(List<? extends Message> messages, Folder folder) throws MessagingException {
        if (!(folder instanceof ImapFolder)) {
            throw new MessagingException("ImapFolder.copyMessages passed non-ImapFolder");
        }

        if (messages.isEmpty()) {
            return null;
        }

        ImapFolder imapFolder = (ImapFolder) folder;
        checkOpen(); //only need READ access

        String[] uids = new String[messages.size()];
        for (int i = 0, count = messages.size(); i < count; i++) {
            uids[i] = messages.get(i).getUid();
        }

        try {
            String encodedDestinationFolderName = folderNameCodec.encode(imapFolder.getPrefixedName());
            String escapedDestinationFolderName = ImapUtility.encodeString(encodedDestinationFolderName);

            //TODO: Try to copy/move the messages first and only create the folder if the
            //      operation fails. This will save a roundtrip if the folder already exists.
            if (!exists(escapedDestinationFolderName)) {
                if (K9MailLib.isDebug()) {
                    Timber.i("ImapFolder.copyMessages: attempting to create remote folder '%s' for %s",
                            escapedDestinationFolderName, getLogId());
                }

                imapFolder.create(FolderType.HOLDS_MESSAGES);
            }

            //TODO: Split this into multiple commands if the command exceeds a certain length.
            List<ImapResponse> responses = executeSimpleCommand(String.format("UID COPY %s %s",
                    combine(uids, ','), escapedDestinationFolderName));

            // Get the tagged response for the UID COPY command
            ImapResponse response = getLastResponse(responses);

            CopyUidResponse copyUidResponse = CopyUidResponse.parse(response);
            if (copyUidResponse == null) {
                return null;
            }

            return copyUidResponse.getUidMapping();
        } catch (IOException ioe) {
            throw ioExceptionHandler(connection, ioe);
        }
    }

    @Override
    public Map<String, String> moveMessages(List<? extends Message> messages, Folder folder) throws MessagingException {
        if (messages.isEmpty()) {
            return null;
        }

        Map<String, String> uidMapping = copyMessages(messages, folder);

        setFlags(messages, Collections.singleton(Flag.DELETED), true);

        return uidMapping;
    }

    @Override
    public void delete(List<? extends Message> messages, String trashFolderName) throws MessagingException {
        if (messages.isEmpty()) {
            return;
        }

        if (trashFolderName == null || getName().equalsIgnoreCase(trashFolderName)) {
            setFlags(messages, Collections.singleton(Flag.DELETED), true);
        } else {
            ImapFolder remoteTrashFolder = getStore().getFolder(trashFolderName);
            String encodedTrashFolderName = folderNameCodec.encode(remoteTrashFolder.getPrefixedName());
            String escapedTrashFolderName = ImapUtility.encodeString(encodedTrashFolderName);

            if (!exists(escapedTrashFolderName)) {
                if (K9MailLib.isDebug()) {
                    Timber.i("IMAPMessage.delete: attempting to create remote '%s' folder for %s",
                            trashFolderName, getLogId());
                }
                remoteTrashFolder.create(FolderType.HOLDS_MESSAGES);
            }

            if (exists(escapedTrashFolderName)) {
                if (K9MailLib.isDebug()) {
                    Timber.d("IMAPMessage.delete: copying remote %d messages to '%s' for %s",
                            messages.size(), trashFolderName, getLogId());
                }

                moveMessages(messages, remoteTrashFolder);
            } else {
                throw new MessagingException("IMAPMessage.delete: remote Trash folder " + trashFolderName +
                        " does not exist and could not be created for " + getLogId(), true);
            }
        }
    }

    @Override
    public int getMessageCount() {
        return messageCount;
    }

    private int getRemoteMessageCount(String criteria) throws MessagingException {
        checkOpen();

        try {
            int count = 0;
            int start = 1;

            String command = String.format(Locale.US, "SEARCH %d:* %s", start, criteria);
            List<ImapResponse> responses = executeSimpleCommand(command);

            for (ImapResponse response : responses) {
                if (ImapResponseParser.equalsIgnoreCase(response.get(0), "SEARCH")) {
                    count += response.size() - 1;
                }
            }

            return count;
        } catch (IOException ioe) {
            throw ioExceptionHandler(connection, ioe);
        }
    }

    @Override
    public int getUnreadMessageCount() throws MessagingException {
        return getRemoteMessageCount("UNSEEN NOT DELETED");
    }

    @Override
    public int getFlaggedMessageCount() throws MessagingException {
        return getRemoteMessageCount("FLAGGED NOT DELETED");
    }

    protected long getHighestUid() throws MessagingException {
        try {
            String command = "UID SEARCH *:*";
            List<ImapResponse> responses = executeSimpleCommand(command);

            SearchResponse searchResponse = SearchResponse.parse(responses);

            return extractHighestUid(searchResponse);
        } catch (NegativeImapResponseException e) {
            return -1L;
        } catch (IOException ioe) {
            throw ioExceptionHandler(connection, ioe);
        }
    }

    private long extractHighestUid(SearchResponse searchResponse) {
        List<Long> uids = searchResponse.getNumbers();
        if (uids.isEmpty()) {
            return -1L;
        }

        if (uids.size() == 1) {
            return uids.get(0);
        }

        Collections.sort(uids, Collections.reverseOrder());

        return uids.get(0);
    }

    @Override
    public void delete(boolean recurse) throws MessagingException {
        throw new Error("ImapFolder.delete() not yet implemented");
    }

    @Override
    public ImapMessage getMessage(String uid) throws MessagingException {
        return new ImapMessage(uid, this);
    }

    @Override
    public List<ImapMessage> getMessages(int start, int end, Date earliestDate,
            MessageRetrievalListener<ImapMessage> listener) throws MessagingException {
        return getMessages(start, end, earliestDate, false, listener);
    }

    protected List<ImapMessage> getMessages(final int start, final int end, Date earliestDate,
            final boolean includeDeleted, final MessageRetrievalListener<ImapMessage> listener)
            throws MessagingException {

        if (start < 1 || end < 1 || end < start) {
            throw new MessagingException(String.format(Locale.US, "Invalid message set %d %d", start, end));
        }

        final String dateSearchString = getDateSearchString(earliestDate);

        ImapSearcher searcher = new ImapSearcher() {
            @Override
            public List<ImapResponse> search() throws IOException, MessagingException {
                String command = String.format(Locale.US, "UID SEARCH %d:%d%s%s", start, end, dateSearchString,
                        includeDeleted ? "" : " NOT DELETED");

                return executeSimpleCommand(command);
            }
        };

        return search(searcher, listener);
    }

    private String getDateSearchString(Date earliestDate) {
        if (earliestDate == null) {
            return "";
        }

        return " SINCE " + RFC3501_DATE.get().format(earliestDate);
    }

    @Override
    public boolean areMoreMessagesAvailable(int indexOfOldestMessage, Date earliestDate) throws IOException,
            MessagingException {

        checkOpen();

        if (indexOfOldestMessage == 1) {
            return false;
        }

        String dateSearchString = getDateSearchString(earliestDate);
        int endIndex = indexOfOldestMessage - 1;

        while (endIndex > 0) {
            int startIndex = Math.max(0, endIndex - MORE_MESSAGES_WINDOW_SIZE) + 1;

            if (existsNonDeletedMessageInRange(startIndex, endIndex, dateSearchString)) {
                return true;
            }

            endIndex = endIndex - MORE_MESSAGES_WINDOW_SIZE;
        }

        return false;
    }

    private boolean existsNonDeletedMessageInRange(int startIndex, int endIndex, String dateSearchString)
            throws MessagingException, IOException {

        String command = String.format(Locale.US, "SEARCH %d:%d%s NOT DELETED", startIndex, endIndex, dateSearchString);
        List<ImapResponse> responses = executeSimpleCommand(command);

        for (ImapResponse response : responses) {
            if (response.getTag() == null && ImapResponseParser.equalsIgnoreCase(response.get(0), "SEARCH")) {
                if (response.size() > 1) {
                    return true;
                }
            }
        }

        return false;
    }

    protected List<ImapMessage> getMessages(final List<Long> mesgSeqs, final boolean includeDeleted,
            final MessageRetrievalListener<ImapMessage> listener) throws MessagingException {
        ImapSearcher searcher = new ImapSearcher() {
            @Override
            public List<ImapResponse> search() throws IOException, MessagingException {
                String command = String.format("UID SEARCH %s%s", combine(mesgSeqs.toArray(), ','),
                        includeDeleted ? "" : " NOT DELETED");

                return executeSimpleCommand(command);
            }
        };

        return search(searcher, listener);
    }

    protected List<ImapMessage> getMessagesFromUids(final List<String> mesgUids) throws MessagingException {
        ImapSearcher searcher = new ImapSearcher() {
            @Override
            public List<ImapResponse> search() throws IOException, MessagingException {
                String command = String.format("UID SEARCH UID %s", combine(mesgUids.toArray(), ','));

                return executeSimpleCommand(command);
            }
        };

        return search(searcher, null);
    }

    private List<ImapMessage> search(ImapSearcher searcher, MessageRetrievalListener<ImapMessage> listener)
            throws MessagingException {
        checkOpen();

        List<ImapMessage> messages = new ArrayList<>();
        try {
            List<ImapResponse> responses = searcher.search();

            SearchResponse searchResponse = SearchResponse.parse(responses);
            List<Long> uids = searchResponse.getNumbers();

            // Sort the uids in numerically decreasing order
            // By doing it in decreasing order, we ensure newest messages are dealt with first
            // This makes the most sense when a limit is imposed, and also prevents UI from going
            // crazy adding stuff at the top.
            Collections.sort(uids, Collections.reverseOrder());

            for (int i = 0, count = uids.size(); i < count; i++) {
                String uid = uids.get(i).toString();
                if (listener != null) {
                    listener.messageStarted(uid, i, count);
                }

                ImapMessage message = new ImapMessage(uid, this);
                messages.add(message);

                if (listener != null) {
                    listener.messageFinished(message, i, count);
                }
            }
        } catch (IOException ioe) {
            throw ioExceptionHandler(connection, ioe);
        }

        return messages;
    }

    @Override
    public void fetch(List<ImapMessage> messages, FetchProfile fetchProfile,
            MessageRetrievalListener<ImapMessage> listener) throws MessagingException {
        if (messages == null || messages.isEmpty()) {
            return;
        }

        checkOpen();

        List<String> uids = new ArrayList<>(messages.size());
        HashMap<String, Message> messageMap = new HashMap<>();
        for (Message message : messages) {
            String uid = message.getUid();
            uids.add(uid);
            messageMap.put(uid, message);
        }

        Set<String> fetchFields = new LinkedHashSet<>();
        fetchFields.add("UID");

        if (fetchProfile.contains(FetchProfile.Item.FLAGS)) {
            fetchFields.add("FLAGS");
        }

        if (fetchProfile.contains(FetchProfile.Item.ENVELOPE)) {
            fetchFields.add("INTERNALDATE");
            fetchFields.add("RFC822.SIZE");
            fetchFields.add("BODY.PEEK[HEADER.FIELDS (date subject from content-type to cc " +
                    "reply-to message-id references in-reply-to " + K9MailLib.IDENTITY_HEADER + ")]");
        }

        if (fetchProfile.contains(FetchProfile.Item.STRUCTURE)) {
            fetchFields.add("BODYSTRUCTURE");
        }

        if (fetchProfile.contains(FetchProfile.Item.BODY_SANE)) {
            int maximumAutoDownloadMessageSize = store.getStoreConfig().getMaximumAutoDownloadMessageSize();
            if (maximumAutoDownloadMessageSize > 0) {
                fetchFields.add(String.format(Locale.US, "BODY.PEEK[]<0.%d>", maximumAutoDownloadMessageSize));
            } else {
                fetchFields.add("BODY.PEEK[]");
            }
        }

        if (fetchProfile.contains(FetchProfile.Item.BODY)) {
            fetchFields.add("BODY.PEEK[]");
        }

        String spaceSeparatedFetchFields = combine(fetchFields.toArray(new String[fetchFields.size()]), ' ');

        for (int windowStart = 0; windowStart < messages.size(); windowStart += (FETCH_WINDOW_SIZE)) {
            int windowEnd = Math.min(windowStart + FETCH_WINDOW_SIZE, messages.size());
            List<String> uidWindow = uids.subList(windowStart, windowEnd);

            try {
                String commaSeparatedUids = combine(uidWindow.toArray(new String[uidWindow.size()]), ',');
                String command = String.format("UID FETCH %s (%s)", commaSeparatedUids, spaceSeparatedFetchFields);
                connection.sendCommand(command, false);

                ImapResponse response;
                int messageNumber = 0;

                ImapResponseCallback callback = null;
                if (fetchProfile.contains(FetchProfile.Item.BODY) ||
                        fetchProfile.contains(FetchProfile.Item.BODY_SANE)) {
                    callback = new FetchBodyCallback(messageMap);
                }

                do {
                    response = connection.readResponse(callback);

                    if (response.getTag() == null && ImapResponseParser.equalsIgnoreCase(response.get(1), "FETCH")) {
                        ImapList fetchList = (ImapList) response.getKeyedValue("FETCH");
                        String uid = fetchList.getKeyedString("UID");
                        long msgSeq = response.getLong(0);
                        if (uid != null) {
                            try {
                                msgSeqUidMap.put(msgSeq, uid);
                                if (K9MailLib.isDebug()) {
                                    Timber.v("Stored uid '%s' for msgSeq %d into map", uid, msgSeq);
                                }
                            } catch (Exception e) {
                                Timber.e("Unable to store uid '%s' for msgSeq %d", uid, msgSeq);
                            }
                        }

                        Message message = messageMap.get(uid);
                        if (message == null) {
                            if (K9MailLib.isDebug()) {
                                Timber.d("Do not have message in messageMap for UID %s for %s", uid, getLogId());
                            }

                            handleUntaggedResponse(response);
                            continue;
                        }

                        if (listener != null) {
                            listener.messageStarted(uid, messageNumber++, messageMap.size());
                        }

                        ImapMessage imapMessage = (ImapMessage) message;
                        Object literal = handleFetchResponse(imapMessage, fetchList);

                        if (literal != null) {
                            if (literal instanceof String) {
                                String bodyString = (String) literal;
                                InputStream bodyStream = new ByteArrayInputStream(bodyString.getBytes());
                                imapMessage.parse(bodyStream);
                            } else if (literal instanceof Integer) {
                                // All the work was done in FetchBodyCallback.foundLiteral()
                            } else {
                                // This shouldn't happen
                                throw new MessagingException("Got FETCH response with bogus parameters");
                            }
                        }

                        if (listener != null) {
                            listener.messageFinished(imapMessage, messageNumber, messageMap.size());
                        }
                    } else {
                        handleUntaggedResponse(response);
                    }

                } while (response.getTag() == null);
            } catch (IOException ioe) {
                throw ioExceptionHandler(connection, ioe);
            }
        }
    }

    @Override
    public void fetchPart(Message message, Part part, MessageRetrievalListener<Message> listener,
            BodyFactory bodyFactory) throws MessagingException {
        checkOpen();

        String partId = part.getServerExtra();

        String fetch;
        if ("TEXT".equalsIgnoreCase(partId)) {
            int maximumAutoDownloadMessageSize = store.getStoreConfig().getMaximumAutoDownloadMessageSize();
            fetch = String.format(Locale.US, "BODY.PEEK[TEXT]<0.%d>", maximumAutoDownloadMessageSize);
        } else {
            fetch = String.format("BODY.PEEK[%s]", partId);
        }

        try {
            String command = String.format("UID FETCH %s (UID %s)", message.getUid(), fetch);
            connection.sendCommand(command, false);

            ImapResponse response;
            int messageNumber = 0;

            ImapResponseCallback callback = new FetchPartCallback(part, bodyFactory);

            do {
                response = connection.readResponse(callback);

                if (response.getTag() == null && ImapResponseParser.equalsIgnoreCase(response.get(1), "FETCH")) {
                    ImapList fetchList = (ImapList) response.getKeyedValue("FETCH");
                    String uid = fetchList.getKeyedString("UID");

                    if (!message.getUid().equals(uid)) {
                        if (K9MailLib.isDebug()) {
                            Timber.d("Did not ask for UID %s for %s", uid, getLogId());
                        }

                        handleUntaggedResponse(response);
                        continue;
                    }

                    if (listener != null) {
                        listener.messageStarted(uid, messageNumber++, 1);
                    }

                    ImapMessage imapMessage = (ImapMessage) message;

                    Object literal = handleFetchResponse(imapMessage, fetchList);

                    if (literal != null) {
                        if (literal instanceof Body) {
                            // Most of the work was done in FetchAttachmentCallback.foundLiteral()
                            MimeMessageHelper.setBody(part, (Body) literal);
                        } else if (literal instanceof String) {
                            String bodyString = (String) literal;
                            InputStream bodyStream = new ByteArrayInputStream(bodyString.getBytes());

                            String contentTransferEncoding =
                                    part.getHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING)[0];
                            String contentType = part.getHeader(MimeHeader.HEADER_CONTENT_TYPE)[0];
                            Body body = bodyFactory.createBody(contentTransferEncoding, contentType, bodyStream);
                            MimeMessageHelper.setBody(part, body);
                        } else {
                            // This shouldn't happen
                            throw new MessagingException("Got FETCH response with bogus parameters");
                        }
                    }

                    if (listener != null) {
                        listener.messageFinished(message, messageNumber, 1);
                    }
                } else {
                    handleUntaggedResponse(response);
                }

            } while (response.getTag() == null);
        } catch (IOException ioe) {
            throw ioExceptionHandler(connection, ioe);
        }
    }

    // Returns value of body field
    private Object handleFetchResponse(ImapMessage message, ImapList fetchList) throws MessagingException {
        Object result = null;
        if (fetchList.containsKey("FLAGS")) {
            ImapList flags = fetchList.getKeyedList("FLAGS");
            if (flags != null) {
                for (int i = 0, count = flags.size(); i < count; i++) {
                    String flag = flags.getString(i);
                    if (flag.equalsIgnoreCase("\\Deleted")) {
                        message.setFlagInternal(Flag.DELETED, true);
                    } else if (flag.equalsIgnoreCase("\\Answered")) {
                        message.setFlagInternal(Flag.ANSWERED, true);
                    } else if (flag.equalsIgnoreCase("\\Seen")) {
                        message.setFlagInternal(Flag.SEEN, true);
                    } else if (flag.equalsIgnoreCase("\\Flagged")) {
                        message.setFlagInternal(Flag.FLAGGED, true);
                    } else if (flag.equalsIgnoreCase("$Forwarded")) {
                        message.setFlagInternal(Flag.FORWARDED, true);
                        /* a message contains FORWARDED FLAG -> so we can also create them */
                        store.getPermanentFlagsIndex().add(Flag.FORWARDED);
                    }
                }
            }
        }

        if (fetchList.containsKey("INTERNALDATE")) {
            Date internalDate = fetchList.getKeyedDate("INTERNALDATE");
            message.setInternalDate(internalDate);
        }

        if (fetchList.containsKey("RFC822.SIZE")) {
            int size = fetchList.getKeyedNumber("RFC822.SIZE");
            message.setSize(size);
        }

        if (fetchList.containsKey("BODYSTRUCTURE")) {
            ImapList bs = fetchList.getKeyedList("BODYSTRUCTURE");
            if (bs != null) {
                try {
                    parseBodyStructure(bs, message, "TEXT");
                } catch (MessagingException e) {
                    if (K9MailLib.isDebug()) {
                        Timber.d(e, "Error handling message for %s", getLogId());
                    }
                    message.setBody(null);
                }
            }
        }

        if (fetchList.containsKey("BODY")) {
            int index = fetchList.getKeyIndex("BODY") + 2;
            int size = fetchList.size();
            if (index < size) {
                result = fetchList.getObject(index);

                // Check if there's an origin octet
                if (result instanceof String) {
                    String originOctet = (String) result;
                    if (originOctet.startsWith("<") && (index + 1) < size) {
                        result = fetchList.getObject(index + 1);
                    }
                }
            }
        }

        return result;
    }

    protected List<ImapResponse> handleUntaggedResponses(List<ImapResponse> responses) {
        for (ImapResponse response : responses) {
            handleUntaggedResponse(response);
        }

        return responses;
    }

    protected void handlePossibleUidNext(ImapResponse response) {
        if (ImapResponseParser.equalsIgnoreCase(response.get(0), "OK") && response.size() > 1) {
            Object bracketedObj = response.get(1);
            if (bracketedObj instanceof ImapList) {
                ImapList bracketed = (ImapList) bracketedObj;

                if (bracketed.size() > 1) {
                    Object keyObj = bracketed.get(0);
                    if (keyObj instanceof String) {
                        String key = (String) keyObj;
                        if ("UIDNEXT".equalsIgnoreCase(key)) {
                            uidNext = bracketed.getLong(1);
                            if (K9MailLib.isDebug()) {
                                Timber.d("Got UidNext = %s for %s", uidNext, getLogId());
                            }
                        }
                    }
                }
            }
        }
    }

    /**
     * Handle an untagged response that the caller doesn't care to handle themselves.
     */
    protected void handleUntaggedResponse(ImapResponse response) {
        if (response.getTag() == null && response.size() > 1) {
            if (ImapResponseParser.equalsIgnoreCase(response.get(1), "EXISTS")) {
                messageCount = response.getNumber(0);
                if (K9MailLib.isDebug()) {
                    Timber.d("Got untagged EXISTS with value %d for %s", messageCount, getLogId());
                }
            }

            handlePossibleUidNext(response);

            if (ImapResponseParser.equalsIgnoreCase(response.get(1), "EXPUNGE") && messageCount > 0) {
                messageCount--;
                if (K9MailLib.isDebug()) {
                    Timber.d("Got untagged EXPUNGE with messageCount %d for %s", messageCount, getLogId());
                }
            }
        }
    }

    private void parseBodyStructure(ImapList bs, Part part, String id) throws MessagingException {
        if (bs.get(0) instanceof ImapList) {
            /*
             * This is a multipart/*
             */
            MimeMultipart mp = MimeMultipart.newInstance();
            for (int i = 0, count = bs.size(); i < count; i++) {
                if (bs.get(i) instanceof ImapList) {
                    /*
                     * For each part in the message we're going to add a new BodyPart and parse
                     * into it.
                     */
                    MimeBodyPart bp = new MimeBodyPart();
                    if (id.equalsIgnoreCase("TEXT")) {
                        parseBodyStructure(bs.getList(i), bp, Integer.toString(i + 1));
                    } else {
                        parseBodyStructure(bs.getList(i), bp, id + "." + (i + 1));
                    }
                    mp.addBodyPart(bp);
                } else {
                    /*
                     * We've got to the end of the children of the part, so now we can find out
                     * what type it is and bail out.
                     */
                    String subType = bs.getString(i);
                    mp.setSubType(subType.toLowerCase(Locale.US));
                    break;
                }
            }
            MimeMessageHelper.setBody(part, mp);
        } else {
            /*
             * This is a body. We need to add as much information as we can find out about
             * it to the Part.
             */

            /*
             *  0| 0  body type
             *  1| 1  body subtype
             *  2| 2  body parameter parenthesized list
             *  3| 3  body id (unused)
             *  4| 4  body description (unused)
             *  5| 5  body encoding
             *  6| 6  body size
             *  -| 7  text lines (only for type TEXT, unused)
             * Extensions (optional):
             *  7| 8  body MD5 (unused)
             *  8| 9  body disposition
             *  9|10  body language (unused)
             * 10|11  body location (unused)
             */

            String type = bs.getString(0);
            String subType = bs.getString(1);
            String mimeType = (type + "/" + subType).toLowerCase(Locale.US);

            ImapList bodyParams = null;
            if (bs.get(2) instanceof ImapList) {
                bodyParams = bs.getList(2);
            }
            String encoding = bs.getString(5);
            int size = bs.getNumber(6);

            if (MimeUtility.isMessage(mimeType)) {
//                  A body type of type MESSAGE and subtype RFC822
//                  contains, immediately after the basic fields, the
//                  envelope structure, body structure, and size in
//                  text lines of the encapsulated message.
//                    [MESSAGE, RFC822, [NAME, Fwd: [#HTR-517941]:  update plans at 1am Friday - Memory allocation - displayware.eml], NIL, NIL, 7BIT, 5974, NIL, [INLINE, [FILENAME*0, Fwd: [#HTR-517941]:  update plans at 1am Friday - Memory all, FILENAME*1, ocation - displayware.eml]], NIL]
                /*
                 * This will be caught by fetch and handled appropriately.
                 */
                throw new MessagingException("BODYSTRUCTURE message/rfc822 not yet supported.");
            }

            /*
             * Set the content type with as much information as we know right now.
             */
            StringBuilder contentType = new StringBuilder();
            contentType.append(mimeType);

            if (bodyParams != null) {
                /*
                 * If there are body params we might be able to get some more information out
                 * of them.
                 */
                for (int i = 0, count = bodyParams.size(); i < count; i += 2) {
                    String paramName = bodyParams.getString(i);
                    String paramValue = bodyParams.getString(i + 1);
                    contentType.append(String.format(";\r\n %s=\"%s\"", paramName, paramValue));
                }
            }

            part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, contentType.toString());

            // Extension items
            ImapList bodyDisposition = null;
            if ("text".equalsIgnoreCase(type) && bs.size() > 9 && bs.get(9) instanceof ImapList) {
                bodyDisposition = bs.getList(9);
            } else if (!("text".equalsIgnoreCase(type)) && bs.size() > 8 && bs.get(8) instanceof ImapList) {
                bodyDisposition = bs.getList(8);
            }

            StringBuilder contentDisposition = new StringBuilder();

            if (bodyDisposition != null && !bodyDisposition.isEmpty()) {
                if (!"NIL".equalsIgnoreCase(bodyDisposition.getString(0))) {
                    contentDisposition.append(bodyDisposition.getString(0).toLowerCase(Locale.US));
                }

                if (bodyDisposition.size() > 1 && bodyDisposition.get(1) instanceof ImapList) {
                    ImapList bodyDispositionParams = bodyDisposition.getList(1);
                    /*
                     * If there is body disposition information we can pull some more information
                     * about the attachment out.
                     */
                    for (int i = 0, count = bodyDispositionParams.size(); i < count; i += 2) {
                        String paramName = bodyDispositionParams.getString(i).toLowerCase(Locale.US);
                        String paramValue = bodyDispositionParams.getString(i + 1);
                        contentDisposition.append(String.format(";\r\n %s=\"%s\"", paramName, paramValue));
                    }
                }
            }

            if (MimeUtility.getHeaderParameter(contentDisposition.toString(), "size") == null) {
                contentDisposition.append(String.format(Locale.US, ";\r\n size=%d", size));
            }

            /*
             * Set the content disposition containing at least the size. Attachment
             * handling code will use this down the road.
             */
            part.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, contentDisposition.toString());

            /*
             * Set the Content-Transfer-Encoding header. Attachment code will use this
             * to parse the body.
             */
            part.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, encoding);

            if (part instanceof ImapMessage) {
                ((ImapMessage) part).setSize(size);
            }

            part.setServerExtra(id);
        }
    }

    /**
     * Appends the given messages to the selected folder.
     *
     * <p>
     * This implementation also determines the new UIDs of the given messages on the IMAP
     * server and changes the messages' UIDs to the new server UIDs.
     * </p>
     *
     * @param messages
     *         The messages to append to the folder.
     *
     * @return The mapping of original message UIDs to the new server UIDs.
     */
    @Override
    public Map<String, String> appendMessages(List<? extends Message> messages) throws MessagingException {
        open(OPEN_MODE_RW);
        checkOpen();

        try {
            Map<String, String> uidMap = new HashMap<>();
            for (Message message : messages) {
                long messageSize = message.calculateSize();

                String encodeFolderName = folderNameCodec.encode(getPrefixedName());
                String escapedFolderName = ImapUtility.encodeString(encodeFolderName);
                String command = String.format(Locale.US, "APPEND %s (%s) {%d}", escapedFolderName,
                        combineFlags(message.getFlags()), messageSize);
                connection.sendCommand(command, false);

                ImapResponse response;
                do {
                    response = connection.readResponse();

                    handleUntaggedResponse(response);

                    if (response.isContinuationRequested()) {
                        EOLConvertingOutputStream eolOut = new EOLConvertingOutputStream(connection.getOutputStream());
                        message.writeTo(eolOut);
                        eolOut.write('\r');
                        eolOut.write('\n');
                        eolOut.flush();
                    }
                } while (response.getTag() == null);

                if (response.size() > 1) {
                    /*
                     * If the server supports UIDPLUS, then along with the APPEND response it
                     * will return an APPENDUID response code, e.g.
                     *
                     * 11 OK [APPENDUID 2 238268] APPEND completed
                     *
                     * We can use the UID included in this response to update our records.
                     */
                    Object responseList = response.get(1);

                    if (responseList instanceof ImapList) {
                        ImapList appendList = (ImapList) responseList;
                        if (appendList.size() >= 3 && appendList.getString(0).equals("APPENDUID")) {
                            String newUid = appendList.getString(2);

                            if (!TextUtils.isEmpty(newUid)) {
                                message.setUid(newUid);
                                uidMap.put(message.getUid(), newUid);
                                continue;
                            }
                        }
                    }
                }

                /*
                 * This part is executed in case the server does not support UIDPLUS or does
                 * not implement the APPENDUID response code.
                 */
                String newUid = getUidFromMessageId(message);
                if (K9MailLib.isDebug()) {
                    Timber.d("Got UID %s for message for %s", newUid, getLogId());
                }

                if (!TextUtils.isEmpty(newUid)) {
                    uidMap.put(message.getUid(), newUid);
                    message.setUid(newUid);
                }
            }

            /*
             * We need uidMap to be null if new UIDs are not available to maintain consistency
             * with the behavior of other similar methods (copyMessages, moveMessages) which
             * return null.
             */
            return (uidMap.isEmpty()) ? null : uidMap;
        } catch (IOException ioe) {
            throw ioExceptionHandler(connection, ioe);
        }
    }

    @Override
    public String getUidFromMessageId(Message message) throws MessagingException {
        try {
            /*
            * Try to find the UID of the message we just appended using the
            * Message-ID header.
            */
            String[] messageIdHeader = message.getHeader("Message-ID");

            if (messageIdHeader.length == 0) {
                if (K9MailLib.isDebug()) {
                    Timber.d("Did not get a message-id in order to search for UID  for %s", getLogId());
                }
                return null;
            }

            String messageId = messageIdHeader[0];
            if (K9MailLib.isDebug()) {
                Timber.d("Looking for UID for message with message-id %s for %s", messageId, getLogId());
            }

            String command = String.format("UID SEARCH HEADER MESSAGE-ID %s", ImapUtility.encodeString(messageId));
            List<ImapResponse> responses = executeSimpleCommand(command);

            for (ImapResponse response : responses) {
                if (response.getTag() == null && ImapResponseParser.equalsIgnoreCase(response.get(0), "SEARCH")
                        && response.size() > 1) {
                    return response.getString(1);
                }
            }

            return null;
        } catch (IOException ioe) {
            throw new MessagingException("Could not find UID for message based on Message-ID", ioe);
        }
    }

    @Override
    public void expunge() throws MessagingException {
        open(OPEN_MODE_RW);
        checkOpen();

        try {
            executeSimpleCommand("EXPUNGE");
        } catch (IOException ioe) {
            throw ioExceptionHandler(connection, ioe);
        }
    }

    private String combineFlags(Iterable<Flag> flags) {
        List<String> flagNames = new ArrayList<String>();
        for (Flag flag : flags) {
            if (flag == Flag.SEEN) {
                flagNames.add("\\Seen");
            } else if (flag == Flag.DELETED) {
                flagNames.add("\\Deleted");
            } else if (flag == Flag.ANSWERED) {
                flagNames.add("\\Answered");
            } else if (flag == Flag.FLAGGED) {
                flagNames.add("\\Flagged");
            } else if (flag == Flag.FORWARDED
                    && (canCreateKeywords || store.getPermanentFlagsIndex().contains(Flag.FORWARDED))) {
                flagNames.add("$Forwarded");
            }
        }

        return combine(flagNames.toArray(new String[flagNames.size()]), ' ');
    }

    @Override
    public void setFlags(Set<Flag> flags, boolean value) throws MessagingException {
        open(OPEN_MODE_RW);
        checkOpen();

        try {
            String command = String.format("UID STORE 1:* %sFLAGS.SILENT (%s)", value ? "+" : "-", combineFlags(flags));
            executeSimpleCommand(command);
        } catch (IOException ioe) {
            throw ioExceptionHandler(connection, ioe);
        }
    }

    @Override
    public String getNewPushState(String oldSerializedPushState, Message message) {
        try {
            String uid = message.getUid();
            long messageUid = Long.parseLong(uid);

            ImapPushState oldPushState = ImapPushState.parse(oldSerializedPushState);

            if (messageUid >= oldPushState.uidNext) {
                long uidNext = messageUid + 1;
                ImapPushState newPushState = new ImapPushState(uidNext);

                return newPushState.toString();
            } else {
                return null;
            }
        } catch (Exception e) {
            Timber.e(e, "Exception while updated push state for %s", getLogId());
            return null;
        }
    }

    @Override
    public void setFlags(List<? extends Message> messages, final Set<Flag> flags, boolean value)
            throws MessagingException {
        open(OPEN_MODE_RW);
        checkOpen();

        String[] uids = new String[messages.size()];
        for (int i = 0, count = messages.size(); i < count; i++) {
            uids[i] = messages.get(i).getUid();
        }

        try {
            String command = String.format("UID STORE %s %sFLAGS.SILENT (%s)", combine(uids, ','), value ? "+" : "-",
                    combineFlags(flags));
            executeSimpleCommand(command);
        } catch (IOException ioe) {
            throw ioExceptionHandler(connection, ioe);
        }
    }

    private void checkOpen() throws MessagingException {
        if (!isOpen()) {
            throw new MessagingException("Folder " + getPrefixedName() + " is not open.");
        }
    }

    private MessagingException ioExceptionHandler(ImapConnection connection, IOException ioe) {
        Timber.e(ioe, "IOException for %s", getLogId());

        if (connection != null) {
            connection.close();
        }

        close();

        return new MessagingException("IO Error", ioe);
    }

    @Override
    public boolean equals(Object other) {
        if (other instanceof ImapFolder) {
            ImapFolder otherFolder = (ImapFolder) other;
            return otherFolder.getName().equalsIgnoreCase(getName());
        }

        return super.equals(other);
    }

    @Override
    public int hashCode() {
        return getName().hashCode();
    }

    private ImapStore getStore() {
        return store;
    }

    protected String getLogId() {
        String id = store.getStoreConfig().toString() + ":" + getName() + "/" + Thread.currentThread().getName();
        if (connection != null) {
            id += "/" + connection.getLogId();
        }

        return id;
    }

    /**
     * Search the remote ImapFolder.
     * @param queryString String to query for.
     * @param requiredFlags Mandatory flags
     * @param forbiddenFlags Flags to exclude
     * @return List of messages found
     * @throws MessagingException On any error.
     */
    @Override
    public List<ImapMessage> search(final String queryString, final Set<Flag> requiredFlags,
            final Set<Flag> forbiddenFlags) throws MessagingException {

        if (!store.getStoreConfig().allowRemoteSearch()) {
            throw new MessagingException("Your settings do not allow remote searching of this account");
        }

        // Setup the searcher
        final ImapSearcher searcher = new ImapSearcher() {
            @Override
            public List<ImapResponse> search() throws IOException, MessagingException {
                String imapQuery = "UID SEARCH ";
                if (requiredFlags != null) {
                    for (Flag flag : requiredFlags) {
                        switch (flag) {
                            case DELETED: {
                                imapQuery += "DELETED ";
                                break;
                            }
                            case SEEN: {
                                imapQuery += "SEEN ";
                                break;
                            }
                            case ANSWERED: {
                                imapQuery += "ANSWERED ";
                                break;
                            }
                            case FLAGGED: {
                                imapQuery += "FLAGGED ";
                                break;
                            }
                            case DRAFT: {
                                imapQuery += "DRAFT ";
                                break;
                            }
                            case RECENT: {
                                imapQuery += "RECENT ";
                                break;
                            }
                            default: {
                                break;
                            }
                        }
                    }
                }

                if (forbiddenFlags != null) {
                    for (Flag flag : forbiddenFlags) {
                        switch (flag) {
                            case DELETED: {
                                imapQuery += "UNDELETED ";
                                break;
                            }
                            case SEEN: {
                                imapQuery += "UNSEEN ";
                                break;
                            }
                            case ANSWERED: {
                                imapQuery += "UNANSWERED ";
                                break;
                            }
                            case FLAGGED: {
                                imapQuery += "UNFLAGGED ";
                                break;
                            }
                            case DRAFT: {
                                imapQuery += "UNDRAFT ";
                                break;
                            }
                            case RECENT: {
                                imapQuery += "UNRECENT ";
                                break;
                            }
                            default: {
                                break;
                            }
                        }
                    }
                }

                String encodedQuery = ImapUtility.encodeString(queryString);
                if (store.getStoreConfig().isRemoteSearchFullText()) {
                    imapQuery += "TEXT " + encodedQuery;
                } else {
                    imapQuery += "OR SUBJECT " + encodedQuery + " FROM " + encodedQuery;
                }

                return executeSimpleCommand(imapQuery);
            }
        };

        try {
            open(OPEN_MODE_RO);
            checkOpen();

            inSearch = true;

            return search(searcher, null);
        } finally {
            inSearch = false;
        }
    }

    private static String combine(Object[] parts, char separator) {
        if (parts == null) {
            return null;
        }

        return TextUtils.join(String.valueOf(separator), parts);
    }
}
