/**
 * Copyright (c) 2011, Google Inc.
 *
 * 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.Activity;
import android.content.ContentProvider;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.CursorLoader;
import android.content.Intent;
import android.content.Loader;
import android.content.Loader.OnLoadCompleteListener;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.Bundle;

import com.android.mail.R;
import com.android.mail.providers.UIProvider.AccountCursorExtraKeys;
import com.android.mail.utils.LogTag;
import com.android.mail.utils.LogUtils;
import com.android.mail.utils.MatrixCursorWithExtra;
import com.android.mail.utils.RankedComparator;
import com.google.android.mail.common.base.Function;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;


/**
 * The Mail App provider allows email providers to register "accounts" and the UI has a single
 * place to query for the list of accounts.
 *
 * During development this will allow new account types to be added, and allow them to be shown in
 * the application.  For example, the mock accounts can be enabled/disabled.
 * In the future, once other processes can add new accounts, this could allow other "mail"
 * applications have their content appear within the application
 */
public abstract class MailAppProvider extends ContentProvider
        implements OnLoadCompleteListener<Cursor>{

    private static final String SHARED_PREFERENCES_NAME = "MailAppProvider";
    private static final String ACCOUNT_LIST_KEY = "accountList";
    private static final String LAST_VIEWED_ACCOUNT_KEY = "lastViewedAccount";
    private static final String LAST_SENT_FROM_ACCOUNT_KEY = "lastSendFromAccount";

    /**
     * Extra used in the result from the activity launched by the intent specified
     * by {@link #getNoAccountsIntent} to return the list of accounts.  The data
     * specified by this extra key should be a ParcelableArray.
     */
    public static final String ADD_ACCOUNT_RESULT_ACCOUNTS_EXTRA = "addAccountResultAccounts";

    private final static String LOG_TAG = LogTag.getLogTag();

    private final LinkedHashMap<Uri, AccountCacheEntry> mAccountCache =
            new LinkedHashMap<Uri, AccountCacheEntry>();

    private final Map<Uri, CursorLoader> mCursorLoaderMap = Maps.newHashMap();
    /**
     * When there is more than one {@link CursorLoader} we are considered finished only when all
     * loaders finish.
     */
    private final Map<CursorLoader, Boolean> mAccountsLoaded = Maps.newHashMap();

    private ContentResolver mResolver;

    /**
     * Compares {@link AccountCacheEntry} based on the position of the
     * {@link AccountCacheEntry#mAccountsQueryUri} in {@code R.array.account_providers}.
     */
    private Comparator<AccountCacheEntry> mAccountComparator;

    private static String sAuthority;
    private static MailAppProvider sInstance;

    private SharedPreferences mSharedPrefs;

    /**
     * Allows the implementing provider to specify the authority for this provider. Email and Gmail
     * must specify different authorities.
     */
    protected abstract String getAuthority();

    /**
     * Allows the implementing provider to specify an intent that should be used in a call to
     * {@link Context#startActivityForResult(android.content.Intent)} when the account provider
     * doesn't return any accounts.
     *
     * The result from the {@link Activity} activity should include the list of accounts in
     * the returned intent, in the

     * @return Intent or null, if the provider doesn't specify a behavior when no accounts are
     * specified.
     */
    protected abstract Intent getNoAccountsIntent(Context context);

    /**
     * The cursor returned from a call to {@link android.content.ContentResolver#query()} with this
     * uri will return a cursor that with columns that are a subset of the columns specified
     * in {@link UIProvider.ConversationColumns}
     * The cursor returned by this query can return a {@link android.os.Bundle}
     * from a call to {@link android.database.Cursor#getExtras()}.  This Bundle may have
     * values with keys listed in {@link AccountCursorExtraKeys}
     */
    public static Uri getAccountsUri() {
        return Uri.parse("content://" + sAuthority + "/");
    }

    public static MailAppProvider getInstance() {
        return sInstance;
    }

    /** Default constructor */
    protected MailAppProvider() {
    }

    @Override
    public boolean onCreate() {
        sAuthority = getAuthority();
        sInstance = this;
        mResolver = getContext().getContentResolver();

        // Load the previously saved account list
        loadCachedAccountList();

        final Resources res = getContext().getResources();
        // Load the uris for the account list
        final String[] accountQueryUris = res.getStringArray(R.array.account_providers);

        final Function<AccountCacheEntry, String> accountQueryUriExtractor =
                new Function<AccountCacheEntry, String>() {
                    @Override
                    public String apply(AccountCacheEntry accountCacheEntry) {
                        if (accountCacheEntry == null) {
                            return null;
                        }
                        return accountCacheEntry.mAccountsQueryUri.toString();
                    }
                };
        mAccountComparator = new RankedComparator<AccountCacheEntry, String>(
                accountQueryUris, accountQueryUriExtractor);

        for (String accountQueryUri : accountQueryUris) {
            final Uri uri = Uri.parse(accountQueryUri);
            addAccountsForUriAsync(uri);
        }

        return true;
    }

    @Override
    public void shutdown() {
        sInstance = null;

        for (CursorLoader loader : mCursorLoaderMap.values()) {
            loader.stopLoading();
        }
        mCursorLoaderMap.clear();
        mAccountsLoaded.clear();
    }

    @Override
    public Cursor query(Uri url, String[] projection, String selection, String[] selectionArgs,
            String sortOrder) {
        // This content provider currently only supports one query (to return the list of accounts).
        // No reason to check the uri.  Currently only checking the projections

        // Validates and returns the projection that should be used.
        final String[] resultProjection = UIProviderValidator.validateAccountProjection(projection);
        final Bundle extras = new Bundle();
        extras.putInt(AccountCursorExtraKeys.ACCOUNTS_LOADED, allAccountsLoaded() ? 1 : 0);

        final List<AccountCacheEntry> accountList;
        synchronized (mAccountCache) {
            accountList = Lists.newArrayList(mAccountCache.values());
        }

        // The order in which providers respond will affect the order of accounts. Because
        // mAccountComparator only compares mAccountsQueryUri it will ensure that they are always
        // sorted first based on that and later based on order returned by each provider.
        Collections.sort(accountList, mAccountComparator);

        final MatrixCursor cursor =
                new MatrixCursorWithExtra(resultProjection, accountList.size(), extras);

        for (AccountCacheEntry accountEntry : accountList) {
            final Account account = accountEntry.mAccount;
            final MatrixCursor.RowBuilder builder = cursor.newRow();
            final Map<String, Object> accountValues = account.getValueMap();

            for (final String columnName : resultProjection) {
                if (accountValues.containsKey(columnName)) {
                    builder.add(accountValues.get(columnName));
                } else {
                    throw new IllegalStateException("Unexpected column: " + columnName);
                }
            }
        }

        cursor.setNotificationUri(mResolver, getAccountsUri());
        return cursor;
    }

    @Override
    public Uri insert(Uri url, ContentValues values) {
        return url;
    }

    @Override
    public int update(Uri url, ContentValues values, String selection,
            String[] selectionArgs) {
        return 0;
    }

    @Override
    public int delete(Uri url, String selection, String[] selectionArgs) {
        return 0;
    }

    @Override
    public String getType(Uri uri) {
        return null;
    }

    /**
     * Asynchronously adds all of the accounts that are specified by the result set returned by
     * {@link ContentProvider#query()} for the specified uri.  The content provider handling the
     * query needs to handle the {@link UIProvider.ACCOUNTS_PROJECTION}
     * Any changes to the underlying provider will automatically be reflected.
     * @param accountsQueryUri
     */
    private void addAccountsForUriAsync(Uri accountsQueryUri) {
        startAccountsLoader(accountsQueryUri);
    }

    /**
     * Returns the intent that should be used in a call to
     * {@link Context#startActivity(android.content.Intent)} when the account provider doesn't
     * return any accounts
     * @return Intent or null, if the provider doesn't specify a behavior when no acccounts are
     * specified.
     */
    public static Intent getNoAccountIntent(Context context) {
        return getInstance().getNoAccountsIntent(context);
    }

    private synchronized void startAccountsLoader(Uri accountsQueryUri) {
        final CursorLoader accountsCursorLoader = new CursorLoader(getContext(), accountsQueryUri,
                UIProvider.ACCOUNTS_PROJECTION, null, null, null);

        // Listen for the results
        accountsCursorLoader.registerListener(accountsQueryUri.hashCode(), this);
        accountsCursorLoader.startLoading();

        // If there is a previous loader for the given uri, stop it
        final CursorLoader oldLoader = mCursorLoaderMap.get(accountsQueryUri);
        if (oldLoader != null) {
            oldLoader.stopLoading();
        }
        mCursorLoaderMap.put(accountsQueryUri, accountsCursorLoader);
        mAccountsLoaded.put(accountsCursorLoader, false);
    }

    private void addAccountImpl(Account account, Uri accountsQueryUri, boolean notify) {
        addAccountImpl(account.uri, new AccountCacheEntry(account, accountsQueryUri));

        // Explicitly calling this out of the synchronized block in case any of the observers get
        // called synchronously.
        if (notify) {
            broadcastAccountChange();
        }
    }

    private void addAccountImpl(Uri key, AccountCacheEntry accountEntry) {
        synchronized (mAccountCache) {
            LogUtils.v(LOG_TAG, "adding account %s", accountEntry.mAccount);
            // LinkedHashMap will not change the iteration order when re-inserting a key
            mAccountCache.put(key, accountEntry);
        }
    }

    private static void broadcastAccountChange() {
        final MailAppProvider provider = sInstance;

        if (provider != null) {
            provider.mResolver.notifyChange(getAccountsUri(), null);
        }
    }

    /**
     * Returns the {@link Account#uri} (in String form) of the last viewed account.
     */
    public String getLastViewedAccount() {
        return getPreferences().getString(LAST_VIEWED_ACCOUNT_KEY, null);
    }

    /**
     * Persists the {@link Account#uri} (in String form) of the last viewed account.
     */
    public void setLastViewedAccount(String accountUriStr) {
        final SharedPreferences.Editor editor = getPreferences().edit();
        editor.putString(LAST_VIEWED_ACCOUNT_KEY, accountUriStr);
        editor.apply();
    }

    /**
     * Returns the {@link Account#uri} (in String form) of the last account the
     * user compose a message from.
     */
    public String getLastSentFromAccount() {
        return getPreferences().getString(LAST_SENT_FROM_ACCOUNT_KEY, null);
    }

    /**
     * Persists the {@link Account#uri} (in String form) of the last account the
     * user compose a message from.
     */
    public void setLastSentFromAccount(String accountUriStr) {
        final SharedPreferences.Editor editor = getPreferences().edit();
        editor.putString(LAST_SENT_FROM_ACCOUNT_KEY, accountUriStr);
        editor.apply();
    }

    private void loadCachedAccountList() {
        JSONArray accounts = null;
        try {
            final String accountsJson = getPreferences().getString(ACCOUNT_LIST_KEY, null);
            if (accountsJson != null) {
                accounts = new JSONArray(accountsJson);
            }
        } catch (Exception e) {
            LogUtils.e(LOG_TAG, e, "ignoring unparsable accounts cache");
        }

        if (accounts == null) {
            return;
        }

        for (int i = 0; i < accounts.length(); i++) {
            try {
                final AccountCacheEntry accountEntry = new AccountCacheEntry(
                        accounts.getJSONObject(i));

                if (accountEntry.mAccount.settings == null) {
                    LogUtils.e(LOG_TAG, "Dropping account that doesn't specify settings");
                    continue;
                }

                Account account = accountEntry.mAccount;
                ContentProviderClient client =
                        mResolver.acquireContentProviderClient(account.uri);
                if (client != null) {
                    client.release();
                    addAccountImpl(account.uri, accountEntry);
                } else {
                    LogUtils.e(LOG_TAG, "Dropping account without provider: %s",
                            account.getEmailAddress());
                }

            } catch (Exception e) {
                // Unable to create account object, skip to next
                LogUtils.e(LOG_TAG, e,
                        "Unable to create account object from serialized form");
            }
        }
        broadcastAccountChange();
    }

    private void cacheAccountList() {
        final List<AccountCacheEntry> accountList;

        synchronized (mAccountCache) {
            accountList = ImmutableList.copyOf(mAccountCache.values());
        }

        final JSONArray arr = new JSONArray();
        for (AccountCacheEntry accountEntry : accountList) {
            arr.put(accountEntry.toJSONObject());
        }

        final SharedPreferences.Editor editor = getPreferences().edit();
        editor.putString(ACCOUNT_LIST_KEY, arr.toString());
        editor.apply();
    }

    private SharedPreferences getPreferences() {
        if (mSharedPrefs == null) {
            mSharedPrefs = getContext().getSharedPreferences(
                    SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE);
        }
        return mSharedPrefs;
    }

    static public Account getAccountFromAccountUri(Uri accountUri) {
        MailAppProvider provider = getInstance();
        if (provider != null && provider.allAccountsLoaded()) {
            synchronized(provider.mAccountCache) {
                AccountCacheEntry entry = provider.mAccountCache.get(accountUri);
                if (entry != null) {
                    return entry.mAccount;
                }
            }
        }
        return null;
    }

    @Override
    public void onLoadComplete(Loader<Cursor> loader, Cursor data) {
        if (data == null) {
            LogUtils.d(LOG_TAG, "null account cursor returned");
            return;
        }

        LogUtils.d(LOG_TAG, "Cursor with %d accounts returned", data.getCount());
        final CursorLoader cursorLoader = (CursorLoader)loader;
        final Uri accountsQueryUri = cursorLoader.getUri();

        // preserve ordering on partial updates
        // also preserve ordering on complete updates for any that existed previously


        final List<AccountCacheEntry> accountList;
        synchronized (mAccountCache) {
            accountList = ImmutableList.copyOf(mAccountCache.values());
        }

        // Build a set of the account uris that had been associated with that query
        final Set<Uri> previousQueryUriSet = Sets.newHashSet();
        for (AccountCacheEntry entry : accountList) {
            if (accountsQueryUri.equals(entry.mAccountsQueryUri)) {
                previousQueryUriSet.add(entry.mAccount.uri);
            }
        }

        // Update the internal state of this provider if the returned result set
        // represents all accounts
        final boolean accountsFullyLoaded =
                data.getExtras().getInt(AccountCursorExtraKeys.ACCOUNTS_LOADED) != 0;
        mAccountsLoaded.put(cursorLoader, accountsFullyLoaded);

        final Set<Uri> newQueryUriSet = Sets.newHashSet();

        // We are relying on the fact that all accounts are added in the order specified in the
        // cursor.  Initially assume that we insert these items to at the end of the list
        while (data.moveToNext()) {
            final Account account = Account.builder().buildFrom(data);
            final Uri accountUri = account.uri;
            newQueryUriSet.add(accountUri);

            // preserve existing order if already present and this is a partial update,
            // otherwise add to the end
            //
            // N.B. this ordering policy means the order in which providers respond will affect
            // the order of accounts.
            if (accountsFullyLoaded) {
                synchronized (mAccountCache) {
                    // removing the existing item will prevent LinkedHashMap from preserving the
                    // original insertion order
                    mAccountCache.remove(accountUri);
                }
            }
            addAccountImpl(account, accountsQueryUri, false /* don't notify */);
        }
        // Remove all of the accounts that are in the new result set
        previousQueryUriSet.removeAll(newQueryUriSet);

        // For all of the entries that had been in the previous result set, and are not
        // in the new result set, remove them from the cache
        if (previousQueryUriSet.size() > 0 && accountsFullyLoaded) {
            synchronized (mAccountCache) {
                for (Uri accountUri : previousQueryUriSet) {
                    LogUtils.d(LOG_TAG, "Removing account %s", accountUri);
                    mAccountCache.remove(accountUri);
                }
            }
        }
        broadcastAccountChange();

        // Cache the updated account list
        cacheAccountList();
    }

    private boolean allAccountsLoaded() {
        for (Boolean loaded : mAccountsLoaded.values()) {
            if (!loaded) {
                return false;
            }
        }
        return true;
    }

    /**
     * Object that allows the Account Cache provider to associate the account with the content
     * provider uri that originated that account.
     */
    private static class AccountCacheEntry {
        final Account mAccount;
        final Uri mAccountsQueryUri;

        private static final String KEY_ACCOUNT = "acct";
        private static final String KEY_QUERY_URI = "queryUri";

        public AccountCacheEntry(Account account, Uri accountQueryUri) {
            mAccount = account;
            mAccountsQueryUri = accountQueryUri;
        }

        public AccountCacheEntry(JSONObject o) throws JSONException {
            mAccount = Account.newInstance(o.getString(KEY_ACCOUNT));
            if (mAccount == null) {
                throw new IllegalArgumentException("AccountCacheEntry de-serializing failed. "
                        + "Account object could not be created from the JSONObject: "
                        + o);
            }
            if (mAccount.settings == Settings.EMPTY_SETTINGS) {
                throw new IllegalArgumentException("AccountCacheEntry de-serializing failed. "
                        + "Settings could not be created from the JSONObject: " + o);
            }
            final String uriStr = o.optString(KEY_QUERY_URI, null);
            if (uriStr != null) {
                mAccountsQueryUri = Uri.parse(uriStr);
            } else {
                mAccountsQueryUri = null;
            }
        }

        public JSONObject toJSONObject() {
            try {
                return new JSONObject()
                .put(KEY_ACCOUNT, mAccount.serialize())
                .putOpt(KEY_QUERY_URI, mAccountsQueryUri);
            } catch (JSONException e) {
                // shouldn't happen
                throw new IllegalArgumentException(e);
            }
        }
    }
}
