package com.fsck.k9.mailstore.migrations;


import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;

import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.text.TextUtils;
import timber.log.Timber;

import com.fsck.k9.Account;
import com.fsck.k9.K9;
import com.fsck.k9.helper.Utility;
import com.fsck.k9.mail.Flag;
import com.fsck.k9.mail.internet.MimeHeader;
import com.fsck.k9.mail.internet.MimeUtility;
import com.fsck.k9.mailstore.StorageManager;
import org.apache.james.mime4j.codec.QuotedPrintableOutputStream;
import org.apache.james.mime4j.util.MimeUtil;


class MigrationTo51 {
    private static final int MESSAGE_PART_TYPE__UNKNOWN = 0;
    private static final int MESSAGE_PART_TYPE__HIDDEN_ATTACHMENT = 6;
    private static final int DATA_LOCATION__MISSING = 0;
    private static final int DATA_LOCATION__IN_DATABASE = 1;
    private static final int DATA_LOCATION__ON_DISK = 2;

    /**
     * This method converts from the old message table structure to the new one.
     *
     * This is a complex migration, and ultimately we do not have enough
     * information to recreate the mime structure of the original mails.
     * What we have:
     *  - general mail info
     *  - html_content and text_content data, which is the squashed readable content of the mail
     *  - a table with message headers
     *  - attachments
     *
     * What we need to do:
     *  - migrate general mail info as-is
     *  - flag mails as migrated for re-download
     *  - for each message, recreate a mime structure from its message content and attachments:
     *    + insert one or both of textContent and htmlContent, depending on mimeType
     *    + if mimeType is text/plain, text/html or multipart/alternative and no
     *      attachments are present, just insert that.
     *    + otherwise, use multipart/mixed, adding attachments after textual content
     *    + revert content:// URIs in htmlContent to original cid: URIs.
     */
    public static void db51MigrateMessageFormat(SQLiteDatabase db, MigrationsHelper migrationsHelper) {
        renameOldMessagesTableAndCreateNew(db);

        copyMessageMetadataToNewTable(db);

        File attachmentDirNew, attachmentDirOld;
        Account account = migrationsHelper.getAccount();
        attachmentDirNew = StorageManager.getInstance(K9.app).getAttachmentDirectory(
                account.getUuid(), account.getLocalStorageProviderId());
        attachmentDirOld = renameOldAttachmentDirAndCreateNew(account, attachmentDirNew);

        Cursor msgCursor = db.query("messages_old",
                new String[] { "id", "flags", "html_content", "text_content", "mime_type", "attachment_count" },
                null, null, null, null, null);
        try {
            Timber.d("migrating %d messages", msgCursor.getCount());
            ContentValues cv = new ContentValues();
            while (msgCursor.moveToNext()) {
                long messageId = msgCursor.getLong(0);
                String messageFlags = msgCursor.getString(1);
                String htmlContent = msgCursor.getString(2);
                String textContent = msgCursor.getString(3);
                String mimeType = msgCursor.getString(4);
                int attachmentCount = msgCursor.getInt(5);

                try {
                    updateFlagsForMessage(db, messageId, messageFlags, migrationsHelper);
                    MimeHeader mimeHeader = loadHeaderFromHeadersTable(db, messageId);

                    MimeStructureState structureState = MimeStructureState.getNewRootState();

                    boolean messageHadSpecialFormat = false;

                    // we do not rely on the protocol parameter here but guess by the multipart structure
                    boolean isMaybePgpMimeEncrypted = attachmentCount == 2
                            && MimeUtil.isSameMimeType(mimeType, "multipart/encrypted");
                    if (isMaybePgpMimeEncrypted) {
                        MimeStructureState maybeStructureState =
                                migratePgpMimeEncryptedContent(db, messageId, attachmentDirOld, attachmentDirNew,
                                        mimeHeader, structureState);
                        if (maybeStructureState != null) {
                            structureState = maybeStructureState;
                            messageHadSpecialFormat = true;
                        }
                    }

                    if (!messageHadSpecialFormat) {
                        boolean isSimpleStructured = attachmentCount == 0 &&
                                Utility.isAnyMimeType(mimeType, "text/plain", "text/html", "multipart/alternative");
                        if (isSimpleStructured) {
                            structureState = migrateSimpleMailContent(db, htmlContent, textContent,
                                    mimeType, mimeHeader, structureState);
                        } else {
                            mimeType = "multipart/mixed";
                            structureState =
                                    migrateComplexMailContent(db, attachmentDirOld, attachmentDirNew, messageId,
                                            htmlContent, textContent, mimeHeader, structureState);
                        }
                    }

                    cv.clear();
                    cv.put("mime_type", mimeType);
                    cv.put("message_part_id", structureState.rootPartId);
                    cv.put("attachment_count", attachmentCount);
                    db.update("messages", cv, "id = ?", new String[] { Long.toString(messageId) });
                } catch (IOException e) {
                    Timber.e(e, "error inserting into database");
                }
            }

        } finally {
            msgCursor.close();
        }

        cleanUpOldAttachmentDirectory(attachmentDirOld);

        dropOldMessagesTable(db);
    }

    @NonNull
    private static File renameOldAttachmentDirAndCreateNew(Account account, File attachmentDirNew) {
        File attachmentDirOld = new File(attachmentDirNew.getParent(),
                account.getUuid() + ".old_attach-" + System.currentTimeMillis());
        boolean moveOk = attachmentDirNew.renameTo(attachmentDirOld);
        if (!moveOk) {
            // TODO escalate?
            Timber.e("Error moving attachment dir! All attachments might be lost!");
        }
        boolean mkdirOk = attachmentDirNew.mkdir();
        if (!mkdirOk) {
            // TODO escalate?
            Timber.e("Error creating new attachment dir!");
        }
        return attachmentDirOld;
    }

    private static void dropOldMessagesTable(SQLiteDatabase db) {
        Timber.d("Migration succeeded, dropping old tables.");
        db.execSQL("DROP TABLE messages_old");
        db.execSQL("DROP TABLE attachments");
        db.execSQL("DROP TABLE headers");
    }

    private static void cleanUpOldAttachmentDirectory(File attachmentDirOld) {
        if (!attachmentDirOld.exists()) {
            Timber.d("Old attachment directory doesn't exist: %s", attachmentDirOld.getAbsolutePath());
            return;
        }
        for (File file : attachmentDirOld.listFiles()) {
            Timber.d("deleting stale attachment file: %s", file.getName());
            if (file.exists() && !file.delete()) {
                Timber.d("Failed to delete stale attachement file: %s", file.getAbsolutePath());
            }
        }

        Timber.d("deleting old attachment directory");
        if (attachmentDirOld.exists() && !attachmentDirOld.delete()) {
            Timber.d("Failed to delete old attachement directory: %s", attachmentDirOld.getAbsolutePath());
        }
    }

    private static void copyMessageMetadataToNewTable(SQLiteDatabase db) {
        db.execSQL("INSERT INTO messages (" +
                "id, deleted, folder_id, uid, subject, date, sender_list, " +
                "to_list, cc_list, bcc_list, reply_to_list, attachment_count, " +
                "internal_date, message_id, preview, mime_type, " +
                "normalized_subject_hash, empty, read, flagged, answered" +
                ") SELECT " +
                "id, deleted, folder_id, uid, subject, date, sender_list, " +
                "to_list, cc_list, bcc_list, reply_to_list, attachment_count, " +
                "internal_date, message_id, preview, mime_type, " +
                "normalized_subject_hash, empty, read, flagged, answered " +
                "FROM messages_old");
    }

    private static void renameOldMessagesTableAndCreateNew(SQLiteDatabase db) {
        db.execSQL("ALTER TABLE messages RENAME TO messages_old");

        db.execSQL("CREATE TABLE messages (" +
                "id INTEGER PRIMARY KEY, " +
                "deleted INTEGER default 0, " +
                "folder_id INTEGER, " +
                "uid TEXT, " +
                "subject TEXT, " +
                "date INTEGER, " +
                "flags TEXT, " +
                "sender_list TEXT, " +
                "to_list TEXT, " +
                "cc_list TEXT, " +
                "bcc_list TEXT, " +
                "reply_to_list TEXT, " +
                "attachment_count INTEGER, " +
                "internal_date INTEGER, " +
                "message_id TEXT, " +
                "preview TEXT, " +
                "mime_type TEXT, "+
                "normalized_subject_hash INTEGER, " +
                "empty INTEGER default 0, " +
                "read INTEGER default 0, " +
                "flagged INTEGER default 0, " +
                "answered INTEGER default 0, " +
                "forwarded INTEGER default 0, " +
                "message_part_id INTEGER" +
                ")");

        db.execSQL("CREATE TABLE message_parts (" +
                "id INTEGER PRIMARY KEY, " +
                "type INTEGER NOT NULL, " +
                "root INTEGER, " +
                "parent INTEGER NOT NULL, " +
                "seq INTEGER NOT NULL, " +
                "mime_type TEXT, " +
                "decoded_body_size INTEGER, " +
                "display_name TEXT, " +
                "header TEXT, " +
                "encoding TEXT, " +
                "charset TEXT, " +
                "data_location INTEGER NOT NULL, " +
                "data BLOB, " +
                "preamble TEXT, " +
                "epilogue TEXT, " +
                "boundary TEXT, " +
                "content_id TEXT, " +
                "server_extra TEXT" +
                ")");

        db.execSQL("CREATE TRIGGER set_message_part_root " +
                "AFTER INSERT ON message_parts " +
                "BEGIN " +
                "UPDATE message_parts SET root=id WHERE root IS NULL AND ROWID = NEW.ROWID; " +
                "END");
    }

    @Nullable
    private static MimeStructureState migratePgpMimeEncryptedContent(SQLiteDatabase db, long messageId,
            File attachmentDirOld, File attachmentDirNew, MimeHeader mimeHeader, MimeStructureState structureState) {

        Timber.d("Attempting to migrate multipart/encrypted as pgp/mime");

        // we only handle attachment count == 2 here, so simply sorting application/pgp-encrypted
        // to the front (and application/octet-stream second) should suffice.
        String orderBy = "(mime_type LIKE 'application/pgp-encrypted') DESC";
        Cursor cursor = db.query("attachments",
                new String[] {
                        "id", "size", "name", "mime_type", "store_data",
                        "content_uri", "content_id", "content_disposition"
                },
                "message_id = ?", new String[] { Long.toString(messageId) }, null, null, orderBy);

        try {
            if (cursor.getCount() != 2) {
                Timber.e("Found multipart/encrypted but bad number of attachments, handling as regular mail");
                return null;
            }

            cursor.moveToFirst();

            long firstPartId = cursor.getLong(0);
            int firstPartSize = cursor.getInt(1);
            String firstPartName = cursor.getString(2);
            String firstPartMimeType = cursor.getString(3);
            String firstPartStoreData = cursor.getString(4);
            String firstPartContentUriString = cursor.getString(5);

            if (!MimeUtil.isSameMimeType(firstPartMimeType, "application/pgp-encrypted")) {
                Timber.e("First part in multipart/encrypted wasn't application/pgp-encrypted, " +
                        "not handling as pgp/mime");
                return null;
            }

            cursor.moveToNext();

            long secondPartId = cursor.getLong(0);
            int secondPartSize = cursor.getInt(1);
            String secondPartName = cursor.getString(2);
            String secondPartMimeType = cursor.getString(3);
            String secondPartStoreData = cursor.getString(4);
            String secondPartContentUriString = cursor.getString(5);

            if (!MimeUtil.isSameMimeType(secondPartMimeType, "application/octet-stream")) {
                Timber.e("First part in multipart/encrypted wasn't application/octet-stream, not handling as pgp/mime");
                return null;
            }

            String boundary = MimeUtility.getHeaderParameter(
                    mimeHeader.getFirstHeader(MimeHeader.HEADER_CONTENT_TYPE), "boundary");
            if (TextUtils.isEmpty(boundary)) {
                boundary = MimeUtil.createUniqueBoundary();
            }
            mimeHeader.setHeader(MimeHeader.HEADER_CONTENT_TYPE,
                    String.format("multipart/encrypted; boundary=\"%s\"; protocol=\"application/pgp-encrypted\"", boundary));

            ContentValues cv = new ContentValues();
            cv.put("type", MESSAGE_PART_TYPE__UNKNOWN);
            cv.put("data_location", DATA_LOCATION__IN_DATABASE);
            cv.put("mime_type", "multipart/encrypted");
            cv.put("header", mimeHeader.toString());
            cv.put("boundary", boundary);
            structureState.applyValues(cv);

            long rootMessagePartId = db.insertOrThrow("message_parts", null, cv);
            structureState = structureState.nextMultipartChild(rootMessagePartId);

            structureState =
                    insertMimeAttachmentPart(db, attachmentDirOld, attachmentDirNew, structureState, firstPartId,
                            firstPartSize, firstPartName, "application/pgp-encrypted", firstPartStoreData,
                            firstPartContentUriString, null, null);

            structureState =
                    insertMimeAttachmentPart(db, attachmentDirOld, attachmentDirNew, structureState, secondPartId,
                            secondPartSize, secondPartName, "application/octet-stream", secondPartStoreData,
                            secondPartContentUriString, null, null);
        } finally {
            cursor.close();
        }

        return structureState;

    }

    private static MimeStructureState migrateComplexMailContent(SQLiteDatabase db,
            File attachmentDirOld, File attachmentDirNew, long messageId, String htmlContent, String textContent,
            MimeHeader mimeHeader, MimeStructureState structureState) throws IOException {
        Timber.d("Processing mail with complex data structure as multipart/mixed");

        String boundary = MimeUtility.getHeaderParameter(
                mimeHeader.getFirstHeader(MimeHeader.HEADER_CONTENT_TYPE), "boundary");
        if (TextUtils.isEmpty(boundary)) {
            boundary = MimeUtil.createUniqueBoundary();
        }
        mimeHeader.setHeader(MimeHeader.HEADER_CONTENT_TYPE,
                String.format("multipart/mixed; boundary=\"%s\";", boundary));

        ContentValues cv = new ContentValues();
        cv.put("type", MESSAGE_PART_TYPE__UNKNOWN);
        cv.put("data_location", DATA_LOCATION__IN_DATABASE);
        cv.put("mime_type", "multipart/mixed");
        cv.put("header", mimeHeader.toString());
        cv.put("boundary", boundary);
        structureState.applyValues(cv);

        long rootMessagePartId = db.insertOrThrow("message_parts", null, cv);
        structureState = structureState.nextMultipartChild(rootMessagePartId);

        if (htmlContent != null) {
            htmlContent = replaceContentUriWithContentIdInHtmlPart(db, messageId, htmlContent);
        }

        if (textContent != null && htmlContent != null) {
            structureState = insertBodyAsMultipartAlternative(db, structureState, null, textContent, htmlContent);
            structureState = structureState.popParent();
        } else if (textContent != null) {
            structureState = insertTextualPartIntoDatabase(db, structureState, null, textContent, false);
        } else if (htmlContent != null) {
            structureState = insertTextualPartIntoDatabase(db, structureState, null, htmlContent, true);
        }

        structureState = insertAttachments(db, attachmentDirOld, attachmentDirNew, messageId, structureState);

        return structureState;
    }

    private static String replaceContentUriWithContentIdInHtmlPart(
            SQLiteDatabase db, long messageId, String htmlContent) {
        Cursor cursor = db.query("attachments", new String[] { "content_uri", "content_id" },
                "content_id IS NOT NULL AND message_id = ?", new String[] { Long.toString(messageId) }, null, null, null);

        try {
            while (cursor.moveToNext()) {
                String contentUriString = cursor.getString(0);
                String contentId = cursor.getString(1);
                // this is not super efficient, but occurs only once or twice
                htmlContent = htmlContent.replaceAll(Pattern.quote(contentUriString), "cid:" + contentId);
            }
        } finally {
            cursor.close();
        }

        return htmlContent;
    }

    private static MimeStructureState migrateSimpleMailContent(SQLiteDatabase db, String htmlContent,
            String textContent, String mimeType, MimeHeader mimeHeader, MimeStructureState structureState)
            throws IOException {
        Timber.d("Processing mail with simple structure");

        if (MimeUtil.isSameMimeType(mimeType, "text/plain")) {
            return insertTextualPartIntoDatabase(db, structureState, mimeHeader, textContent, false);
        } else if (MimeUtil.isSameMimeType(mimeType, "text/html")) {
            return insertTextualPartIntoDatabase(db, structureState, mimeHeader, htmlContent, true);
        } else if (MimeUtil.isSameMimeType(mimeType, "multipart/alternative")) {
            return insertBodyAsMultipartAlternative(db, structureState, mimeHeader, textContent, htmlContent);
        } else {
            throw new IllegalStateException("migrateSimpleMailContent cannot handle mimeType " + mimeType);
        }
    }

    private static MimeStructureState insertAttachments(SQLiteDatabase db, File attachmentDirOld, File attachmentDirNew,
            long messageId, MimeStructureState structureState) {
        Cursor cursor = db.query("attachments",
                new String[] {
                        "id", "size", "name", "mime_type", "store_data",
                        "content_uri", "content_id", "content_disposition"
                },
                "message_id = ?", new String[] { Long.toString(messageId) }, null, null, null);

        try {
            while (cursor.moveToNext()) {
                long id = cursor.getLong(0);
                int size = cursor.getInt(1);
                String name = cursor.getString(2);
                String mimeType = cursor.getString(3);
                String storeData = cursor.getString(4);
                String contentUriString = cursor.getString(5);
                String contentId = cursor.getString(6);
                String contentDisposition = cursor.getString(7);

                structureState =
                        insertMimeAttachmentPart(db, attachmentDirOld, attachmentDirNew, structureState, id, size, name,
                                mimeType, storeData, contentUriString, contentId, contentDisposition);

            }
        } finally {
            cursor.close();
        }

        return structureState;
    }

    private static MimeStructureState insertMimeAttachmentPart(SQLiteDatabase db, File attachmentDirOld,
            File attachmentDirNew, MimeStructureState structureState, long id, int size, String name, String mimeType,
            String storeData, String contentUriString, String contentId, String contentDisposition) {

        Timber.d("processing attachment %d, %s, %s, %s, %s",
                id,
                name,
                mimeType,
                storeData,
                contentUriString);

        if (contentDisposition == null) {
            contentDisposition = "attachment";
        }

        MimeHeader mimeHeader = new MimeHeader();
        mimeHeader.setHeader(MimeHeader.HEADER_CONTENT_TYPE,
                String.format("%s;\r\n name=\"%s\"", mimeType, name));
        mimeHeader.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION,
                String.format(Locale.US, "%s;\r\n filename=\"%s\";\r\n size=%d",
                        contentDisposition, name, size)); // TODO: Should use encoded word defined in RFC 2231.
        if (contentId != null) {
            mimeHeader.setHeader(MimeHeader.HEADER_CONTENT_ID, contentId);
        }

        boolean hasData = contentUriString != null;
        File attachmentFileToMove;
        if (hasData) {
            try {
                Uri contentUri = Uri.parse(contentUriString);
                List<String> pathSegments = contentUri.getPathSegments();
                String attachmentId = pathSegments.get(1);
                boolean isMatchingAttachmentId = Long.parseLong(attachmentId) == id;

                File attachmentFile = new File(attachmentDirOld, attachmentId);
                boolean isExistingAttachmentFile = attachmentFile.exists();

                if (!isMatchingAttachmentId) {
                    Timber.e("mismatched attachment id. mark as missing");
                    attachmentFileToMove = null;
                } else if (!isExistingAttachmentFile) {
                    Timber.e("attached file doesn't exist. mark as missing");
                    attachmentFileToMove = null;
                } else {
                    attachmentFileToMove = attachmentFile;
                }
            } catch (Exception e) {
                // anything here fails, conservatively assume the data doesn't exist
                attachmentFileToMove = null;
            }
        } else {
            attachmentFileToMove = null;
        }
        if (attachmentFileToMove == null) {
            Timber.d("matching attachment is in local cache");
        }

        boolean hasContentTypeAndIsInline = !TextUtils.isEmpty(contentId) && "inline".equalsIgnoreCase(contentDisposition);
        int messageType = hasContentTypeAndIsInline ?
                MESSAGE_PART_TYPE__HIDDEN_ATTACHMENT : MESSAGE_PART_TYPE__UNKNOWN;

        ContentValues cv = new ContentValues();
        cv.put("type", messageType);
        cv.put("mime_type", mimeType);
        cv.put("decoded_body_size", size);
        cv.put("display_name", name);
        cv.put("header", mimeHeader.toString());
        cv.put("encoding", MimeUtil.ENC_BINARY);
        cv.put("data_location", attachmentFileToMove != null ? DATA_LOCATION__ON_DISK : DATA_LOCATION__MISSING);
        cv.put("content_id", contentId);
        cv.put("server_extra", storeData);
        structureState.applyValues(cv);

        long partId = db.insertOrThrow("message_parts", null, cv);
        structureState = structureState.nextChild(partId);

        if (attachmentFileToMove != null) {
            boolean moveOk = attachmentFileToMove.renameTo(new File(attachmentDirNew, Long.toString(partId)));
            if (!moveOk) {
                Timber.e("Moving attachment to new dir failed!");
            }
        }
        return structureState;
    }

    private static void updateFlagsForMessage(SQLiteDatabase db, long messageId, String messageFlags,
            MigrationsHelper migrationsHelper) {
        List<Flag> extraFlags = new ArrayList<>();
        if (messageFlags != null && messageFlags.length() > 0) {
            String[] flags = messageFlags.split(",");

            for (String flagStr : flags) {
                try {
                    Flag flag = Flag.valueOf(flagStr);
                    extraFlags.add(flag);
                } catch (Exception e) {
                    // Ignore bad flags
                }
            }
        }
        extraFlags.add(Flag.X_MIGRATED_FROM_V50);

        String flagsString = migrationsHelper.serializeFlags(extraFlags);
        db.execSQL("UPDATE messages SET flags = ? WHERE id = ?", new Object[] { flagsString, messageId } );
    }

    private static MimeStructureState insertBodyAsMultipartAlternative(SQLiteDatabase db,
            MimeStructureState structureState, MimeHeader mimeHeader,
            String textContent, String htmlContent) throws IOException {
        if (mimeHeader == null) {
            mimeHeader = new MimeHeader();
        }
        String boundary = MimeUtility.getHeaderParameter(
                mimeHeader.getFirstHeader(MimeHeader.HEADER_CONTENT_TYPE), "boundary");
        if (TextUtils.isEmpty(boundary)) {
            boundary = MimeUtil.createUniqueBoundary();
        }
        mimeHeader.setHeader(MimeHeader.HEADER_CONTENT_TYPE,
                String.format("multipart/alternative; boundary=\"%s\";", boundary));

        int dataLocation = textContent != null || htmlContent != null
                ? DATA_LOCATION__IN_DATABASE : DATA_LOCATION__MISSING;

        ContentValues cv = new ContentValues();
        cv.put("type", MESSAGE_PART_TYPE__UNKNOWN);
        cv.put("data_location", dataLocation);
        cv.put("mime_type", "multipart/alternative");
        cv.put("header", mimeHeader.toString());
        cv.put("boundary", boundary);
        structureState.applyValues(cv);

        long multipartAlternativePartId = db.insertOrThrow("message_parts", null, cv);
        structureState = structureState.nextMultipartChild(multipartAlternativePartId);

        if (textContent != null) {
            structureState = insertTextualPartIntoDatabase(db, structureState, null, textContent, false);
        }

        if (htmlContent != null) {
            structureState = insertTextualPartIntoDatabase(db, structureState, null, htmlContent, true);
        }

        return structureState;
    }

    private static MimeStructureState insertTextualPartIntoDatabase(SQLiteDatabase db, MimeStructureState structureState,
            MimeHeader mimeHeader, String content, boolean isHtml) throws IOException {
        if (mimeHeader == null) {
            mimeHeader = new MimeHeader();
        }
        mimeHeader.setHeader(MimeHeader.HEADER_CONTENT_TYPE,
                isHtml ? "text/html; charset=\"utf-8\"" : "text/plain; charset=\"utf-8\"");
        mimeHeader.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, MimeUtil.ENC_QUOTED_PRINTABLE);

        byte[] contentBytes;
        int decodedBodySize;
        int dataLocation;
        if (content != null) {
            ByteArrayOutputStream contentOutputStream = new ByteArrayOutputStream();
            QuotedPrintableOutputStream quotedPrintableOutputStream =
                    new QuotedPrintableOutputStream(contentOutputStream, false);
            quotedPrintableOutputStream.write(content.getBytes());
            quotedPrintableOutputStream.flush();

            dataLocation = DATA_LOCATION__IN_DATABASE;
            contentBytes = contentOutputStream.toByteArray();
            decodedBodySize = content.length();
        } else {
            dataLocation = DATA_LOCATION__MISSING;
            contentBytes = null;
            decodedBodySize = 0;
        }

        ContentValues cv = new ContentValues();
        cv.put("type", MESSAGE_PART_TYPE__UNKNOWN);
        cv.put("data_location", dataLocation);
        cv.put("mime_type", isHtml ? "text/html" : "text/plain");
        cv.put("header", mimeHeader.toString());
        cv.put("data", contentBytes);
        cv.put("decoded_body_size", decodedBodySize);
        cv.put("encoding", MimeUtil.ENC_QUOTED_PRINTABLE);
        cv.put("charset", "utf-8");
        structureState.applyValues(cv);

        long partId = db.insertOrThrow("message_parts", null, cv);
        return structureState.nextChild(partId);
    }

    private static MimeHeader loadHeaderFromHeadersTable(SQLiteDatabase db, long messageId) {
        Cursor headersCursor = db.query("headers",
                new String[] { "name", "value" },
                "message_id = ?", new String[] { Long.toString(messageId) }, null, null, null);
        try {
            MimeHeader mimeHeader = new MimeHeader();
            while (headersCursor.moveToNext()) {
                String name = headersCursor.getString(0);
                String value = headersCursor.getString(1);
                mimeHeader.addHeader(name, value);
            }
            return mimeHeader;
        } finally {
            headersCursor.close();
        }
    }


    /**
     * Objects of this class hold immutable information on a database position for
     * one part of the mime structure of a message.
     *
     * An object of this class must be passed to and returned by every operation
     * which inserts mime parts into the database. Each mime part which is inserted
     * must call the {#applyValues()} method on its ContentValues, then obtain the
     * next state object by calling the appropriate next*() method.
     *
     * While the data carried by this object is immutable, it contains some state
     * to ensure that the operations are called correctly and in order.
     *
     * Because the insertion operations required for the database migration are
     * strictly linear, we do not require a more complex stack-based data structure
     * here.
     */
    @VisibleForTesting
    static class MimeStructureState {
        private final Long rootPartId;
        private final Long prevParentId;
        private final long parentId;
        private final int nextOrder;

        // just some diagnostic state to make sure all operations are called in order
        private boolean isValuesApplied;
        private boolean isStateAdvanced;


        private MimeStructureState(Long rootPartId, Long prevParentId, long parentId, int nextOrder) {
            this.rootPartId = rootPartId;
            this.prevParentId = prevParentId;
            this.parentId = parentId;
            this.nextOrder = nextOrder;
        }

        public static MimeStructureState getNewRootState() {
            return new MimeStructureState(null, null, -1, 0);
        }

        public MimeStructureState nextChild(long newPartId) {
            if (!isValuesApplied || isStateAdvanced) {
                throw new IllegalStateException("next* methods must only be called once");
            }
            isStateAdvanced = true;

            if (rootPartId == null) {
                return new MimeStructureState(newPartId, null, -1, nextOrder+1);
            }
            return new MimeStructureState(rootPartId, prevParentId, parentId, nextOrder+1);
        }

        public MimeStructureState nextMultipartChild(long newPartId) {
            if (!isValuesApplied || isStateAdvanced) {
                throw new IllegalStateException("next* methods must only be called once");
            }
            isStateAdvanced = true;

            if (rootPartId == null) {
                return new MimeStructureState(newPartId, parentId, newPartId, nextOrder+1);
            }
            return new MimeStructureState(rootPartId, parentId, newPartId, nextOrder+1);
        }

        public void applyValues(ContentValues cv) {
            if (isValuesApplied || isStateAdvanced) {
                throw new IllegalStateException("applyValues must be called exactly once, after a call to next*");
            }
            if (rootPartId != null && parentId == -1L) {
                throw new IllegalStateException("applyValues must not be called after a root nextChild call");
            }
            isValuesApplied = true;

            if (rootPartId != null) {
                cv.put("root", rootPartId);
            }
            cv.put("parent", parentId);
            cv.put("seq", nextOrder);
        }

        public MimeStructureState popParent() {
            if (prevParentId == null) {
                throw new IllegalStateException("popParent must only be called if parent depth is >= 2");
            }
            return new MimeStructureState(rootPartId, null, prevParentId, nextOrder);
        }
    }
}
