/*
 * Copyright (C) 2013 Google Inc.
 * Licensed to The Android Open Source Project.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.mail.providers;

import android.app.DownloadManager;
import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.Environment;
import android.os.ParcelFileDescriptor;
import android.os.SystemClock;
import android.text.TextUtils;

import com.android.ex.photo.provider.PhotoContract;
import com.android.mail.R;
import com.android.mail.utils.LogTag;
import com.android.mail.utils.LogUtils;
import com.android.mail.utils.MimeType;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
import java.util.Map;

/**
 * A {@link ContentProvider} for attachments created from eml files.
 * Supports all of the semantics (query/insert/update/delete/openFile)
 * of the regular attachment provider.
 *
 * One major difference is that all attachment info is stored in memory (with the
 * exception of the attachment raw data which is stored in the cache). When
 * the process is killed, all of the attachments disappear if they still
 * exist.
 */
public class EmlAttachmentProvider extends ContentProvider {
    private static final String LOG_TAG = LogTag.getLogTag();

    private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
    private static boolean sUrisAddedToMatcher = false;

    private static final int ATTACHMENT_LIST = 0;
    private static final int ATTACHMENT = 1;
    private static final int ATTACHMENT_BY_CID = 2;

    /**
     * The buffer size used to copy data from cache to sd card.
     */
    private static final int BUFFER_SIZE = 4096;

    /** Any IO reads should be limited to this timeout */
    private static final long READ_TIMEOUT = 3600 * 1000;

    private static Uri BASE_URI;

    private DownloadManager mDownloadManager;

    /**
     * Map that contains a mapping from an attachment list uri to a list of uris.
     */
    private Map<Uri, List<Uri>> mUriListMap;

    /**
     * Map that contains a mapping from an attachment uri to an {@link Attachment} object.
     */
    private Map<Uri, Attachment> mUriAttachmentMap;


    @Override
    public boolean onCreate() {
        final String authority =
                getContext().getResources().getString(R.string.eml_attachment_provider);
        BASE_URI = new Uri.Builder().scheme("content").authority(authority).build();

        if (!sUrisAddedToMatcher) {
            sUrisAddedToMatcher = true;
            sUriMatcher.addURI(authority, "attachments/*/*", ATTACHMENT_LIST);
            sUriMatcher.addURI(authority, "attachment/*/*/#", ATTACHMENT);
            sUriMatcher.addURI(authority, "attachmentByCid/*/*/*", ATTACHMENT_BY_CID);
        }

        mDownloadManager =
                (DownloadManager) getContext().getSystemService(Context.DOWNLOAD_SERVICE);

        mUriListMap = Maps.newHashMap();
        mUriAttachmentMap = Maps.newHashMap();
        return true;
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
            String sortOrder) {
        final int match = sUriMatcher.match(uri);
        // ignore other projections
        final MatrixCursor cursor = new MatrixCursor(UIProvider.ATTACHMENT_PROJECTION);
        final ContentResolver cr = getContext().getContentResolver();

        switch (match) {
            case ATTACHMENT_LIST: {
                final List<String> contentTypeQueryParameters =
                        uri.getQueryParameters(PhotoContract.ContentTypeParameters.CONTENT_TYPE);
                uri = uri.buildUpon().clearQuery().build();
                final List<Uri> attachmentUris = mUriListMap.get(uri);
                for (final Uri attachmentUri : attachmentUris) {
                    addRow(cursor, attachmentUri, contentTypeQueryParameters);
                }
                cursor.setNotificationUri(cr, uri);
                break;
            }
            case ATTACHMENT: {
                addRow(cursor, mUriAttachmentMap.get(uri));
                cursor.setNotificationUri(cr, getListUriFromAttachmentUri(uri));
                break;
            }
            case ATTACHMENT_BY_CID: {
                // form the attachment lists uri by clipping off the cid from the given uri
                final Uri attachmentsListUri = getListUriFromAttachmentUri(uri);
                final String cid = uri.getPathSegments().get(3);

                // find all uris for the parent message
                final List<Uri> attachmentUris = mUriListMap.get(attachmentsListUri);

                if (attachmentUris != null) {
                    // find the attachment that contains the given cid
                    for (Uri attachmentsUri : attachmentUris) {
                        final Attachment attachment = mUriAttachmentMap.get(attachmentsUri);
                        if (TextUtils.equals(cid, attachment.partId)) {
                            addRow(cursor, attachment);
                            cursor.setNotificationUri(cr, attachmentsListUri);
                            break;
                        }
                    }
                }
                break;
            }
            default:
                break;
        }

        return cursor;
    }

    @Override
    public String getType(Uri uri) {
        final int match = sUriMatcher.match(uri);
        switch (match) {
            case ATTACHMENT:
                return mUriAttachmentMap.get(uri).getContentType();
            default:
                return null;
        }
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {
        final Uri listUri = getListUriFromAttachmentUri(uri);

        // add mapping from uri to attachment
        if (mUriAttachmentMap.put(uri, new Attachment(values)) == null) {
            // only add uri to list if the list
            // get list of attachment uris, creating if necessary
            List<Uri> list = mUriListMap.get(listUri);
            if (list == null) {
                list = Lists.newArrayList();
                mUriListMap.put(listUri, list);
            }

            list.add(uri);
        }

        return uri;
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {
        final int match = sUriMatcher.match(uri);
        switch (match) {
            case ATTACHMENT_LIST:
                // remove from list mapping
                final List<Uri> attachmentUris = mUriListMap.remove(uri);

                // delete each file and remove each element from the mapping
                for (final Uri attachmentUri : attachmentUris) {
                    mUriAttachmentMap.remove(attachmentUri);
                }

                deleteDirectory(getCacheFileDirectory(uri));
                // return rows affected
                return attachmentUris.size();
            default:
                return 0;
        }
    }

    @Override
    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
        final int match = sUriMatcher.match(uri);
        switch (match) {
            case ATTACHMENT:
                return copyAttachment(uri, values);
            default:
                return 0;
        }
    }

    /**
     * Adds a row to the cursor for the attachment at the specific attachment {@link Uri}
     * if the attachment's mime type matches one of the query parameters.
     *
     * Matching is defined to be starting with one of the query parameters. If no
     * parameters exist, all rows are added.
     */
    private void addRow(MatrixCursor cursor, Uri uri,
            List<String> contentTypeQueryParameters) {
        final Attachment attachment = mUriAttachmentMap.get(uri);

        if (contentTypeQueryParameters != null && !contentTypeQueryParameters.isEmpty()) {
            for (final String type : contentTypeQueryParameters) {
                if (attachment.getContentType().startsWith(type)) {
                    addRow(cursor, attachment);
                    return;
                }
            }
        } else {
            addRow(cursor, attachment);
        }
    }

    /**
     * Adds a new row to the cursor for the specific attachment.
     */
    private static void addRow(MatrixCursor cursor, Attachment attachment) {
        cursor.newRow()
                .add(attachment.getName())                          // displayName
                .add(attachment.size)                               // size
                .add(attachment.uri)                                // uri
                .add(attachment.getContentType())                   // contentType
                .add(attachment.state)                              // state
                .add(attachment.destination)                        // destination
                .add(attachment.downloadedSize)                     // downloadedSize
                .add(attachment.contentUri)                         // contentUri
                .add(attachment.thumbnailUri)                       // thumbnailUri
                .add(attachment.previewIntentUri)                   // previewIntentUri
                .add(attachment.providerData)                       // providerData
                .add(attachment.supportsDownloadAgain() ? 1 : 0)    // supportsDownloadAgain
                .add(attachment.type)                               // type
                .add(attachment.flags)                              // flags
                .add(attachment.partId);                            // partId (same as RFC822 cid)
    }

    /**
     * Copies an attachment at the specified {@link Uri}
     * from cache to the external downloads directory (usually the sd card).
     * @return the number of attachments affected. Should be 1 or 0.
     */
    private int copyAttachment(Uri uri, ContentValues values) {
        final Integer newState = values.getAsInteger(UIProvider.AttachmentColumns.STATE);
        final Integer newDestination =
                values.getAsInteger(UIProvider.AttachmentColumns.DESTINATION);
        if (newState == null && newDestination == null) {
            return 0;
        }

        final int destination = newDestination != null ?
                newDestination.intValue() : UIProvider.AttachmentDestination.CACHE;
        final boolean saveToSd =
                destination == UIProvider.AttachmentDestination.EXTERNAL;

        final Attachment attachment = mUriAttachmentMap.get(uri);

        // 1. check if already saved to sd (via uri save to sd)
        // and return if so (we shouldn't ever be here)

        // if the call was not to save to sd or already saved to sd, just bail out
        if (!saveToSd || attachment.isSavedToExternal()) {
            return 0;
        }


        // 2. copy file
        final String oldFilePath = getFilePath(uri);

        // update the destination before getting the new file path
        // otherwise it will just point to the old location.
        attachment.destination = UIProvider.AttachmentDestination.EXTERNAL;
        final String newFilePath = getFilePath(uri);

        InputStream inputStream = null;
        OutputStream outputStream = null;

        try {
            try {
                inputStream = new FileInputStream(oldFilePath);
            } catch (FileNotFoundException e) {
                LogUtils.e(LOG_TAG, "File not found for file %s", oldFilePath);
                return 0;
            }
            try {
                outputStream = new FileOutputStream(newFilePath);
            } catch (FileNotFoundException e) {
                LogUtils.e(LOG_TAG, "File not found for file %s", newFilePath);
                return 0;
            }
            try {
                final long now = SystemClock.elapsedRealtime();
                final byte data[] = new byte[BUFFER_SIZE];
                int size = 0;
                while (true) {
                    final int len = inputStream.read(data);
                    if (len != -1) {
                        outputStream.write(data, 0, len);

                        size += len;
                    } else {
                        break;
                    }
                    if (SystemClock.elapsedRealtime() - now > READ_TIMEOUT) {
                        throw new IOException("Timed out copying attachment.");
                    }
                }

                // if the attachment is an APK, change contentUri to be a direct file uri
                if (MimeType.isInstallable(attachment.getContentType())) {
                    attachment.contentUri = Uri.parse("file://" + newFilePath);
                }

                // 3. add file to download manager

                try {
                    // TODO - make a better description
                    final String description = attachment.getName();
                    mDownloadManager.addCompletedDownload(attachment.getName(),
                            description, true, attachment.getContentType(),
                            newFilePath, size, false);
                }
                catch (IllegalArgumentException e) {
                    // Even if we cannot save the download to the downloads app,
                    // (likely due to a bad mimeType), we still want to save it.
                    LogUtils.e(LOG_TAG, e, "Failed to save download to Downloads app.");
                }
                final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
                intent.setData(Uri.parse("file://" + newFilePath));
                getContext().sendBroadcast(intent);

                // 4. delete old file
                new File(oldFilePath).delete();
            } catch (IOException e) {
                // Error writing file, delete partial file
                LogUtils.e(LOG_TAG, e, "Cannot write to file %s", newFilePath);
                new File(newFilePath).delete();
            }
        } finally {
            try {
                if (inputStream != null) {
                    inputStream.close();
                }
            } catch (IOException e) {
            }
            try {
                if (outputStream != null) {
                    outputStream.close();
                }
            } catch (IOException e) {
            }
        }

        // 5. notify that the list of attachments has changed so the UI will update
        getContext().getContentResolver().notifyChange(
                getListUriFromAttachmentUri(uri), null, false);
        return 1;
    }

    @Override
    public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
        final String filePath = getFilePath(uri);

        final int fileMode;

        if ("rwt".equals(mode)) {
            fileMode = ParcelFileDescriptor.MODE_READ_WRITE |
                    ParcelFileDescriptor.MODE_TRUNCATE |
                    ParcelFileDescriptor.MODE_CREATE;
        } else if ("rw".equals(mode)) {
            fileMode = ParcelFileDescriptor.MODE_READ_WRITE | ParcelFileDescriptor.MODE_CREATE;
        } else {
            fileMode = ParcelFileDescriptor.MODE_READ_ONLY;
        }

        return ParcelFileDescriptor.open(new File(filePath), fileMode);
    }

    /**
     * Returns an attachment list uri for the specific attachment uri passed.
     */
    private static Uri getListUriFromAttachmentUri(Uri uri) {
        final List<String> segments = uri.getPathSegments();
        return BASE_URI.buildUpon()
                .appendPath("attachments")
                .appendPath(segments.get(1))
                .appendPath(segments.get(2))
                .build();
    }

    /**
     * Returns an attachment list uri for an eml file at the given uri with the given message id.
     */
    public static Uri getAttachmentsListUri(Uri emlFileUri, String messageId) {
        return BASE_URI.buildUpon()
                .appendPath("attachments")
                .appendPath(Integer.toString(emlFileUri.hashCode()))
                .appendPath(messageId)
                .build();
    }

    /**
     * Returns an attachment uri for an eml file at the given uri with the given message id.
     * The consumer of this uri must append a specific CID to it to complete the uri.
     */
    public static Uri getAttachmentByCidUri(Uri emlFileUri, String messageId) {
        return BASE_URI.buildUpon()
                .appendPath("attachmentByCid")
                .appendPath(Integer.toString(emlFileUri.hashCode()))
                .appendPath(messageId)
                .build();
    }

    /**
     * Returns an attachment uri for an attachment from the given eml file uri with
     * the given message id and part id.
     */
    public static Uri getAttachmentUri(Uri emlFileUri, String messageId, String partId) {
        return BASE_URI.buildUpon()
                .appendPath("attachment")
                .appendPath(Integer.toString(emlFileUri.hashCode()))
                .appendPath(messageId)
                .appendPath(partId)
                .build();
    }

    /**
     * Returns the absolute file path for the attachment at the given uri.
     */
    private String getFilePath(Uri uri) {
        final Attachment attachment = mUriAttachmentMap.get(uri);
        final boolean saveToSd =
                attachment.destination == UIProvider.AttachmentDestination.EXTERNAL;
        final String pathStart = (saveToSd) ?
                Environment.getExternalStoragePublicDirectory(
                Environment.DIRECTORY_DOWNLOADS).getAbsolutePath() : getCacheDir();

        // we want the root of the downloads directory if the attachment is
        // saved to external (or we're saving to external)
        final String directoryPath = (saveToSd) ? pathStart : pathStart + uri.getEncodedPath();

        final File directory = new File(directoryPath);
        if (!directory.exists()) {
            directory.mkdirs();
        }
        return directoryPath + "/" + attachment.getName();
    }

    /**
     * Returns the root directory for the attachments for the specific uri.
     */
    private String getCacheFileDirectory(Uri uri) {
        return getCacheDir() + "/" + Uri.encode(uri.getPathSegments().get(1));
    }

    /**
     * Returns the cache directory for eml attachment files.
     */
    private String getCacheDir() {
        return getContext().getCacheDir().getAbsolutePath().concat("/eml");
    }

    /**
     * Recursively delete the directory at the passed file path.
     */
    private void deleteDirectory(String cacheFileDirectory) {
        recursiveDelete(new File(cacheFileDirectory));
    }

    /**
     * Recursively deletes a file or directory.
     */
    private void recursiveDelete(File file) {
        if (file.isDirectory()) {
            final File[] children = file.listFiles();
            for (final File child : children) {
                recursiveDelete(child);
            }
        }

        file.delete();
    }
}
