/*
 * Copyright (C) 2015 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.providers.contacts;

import android.content.ContentProvider;
import android.content.ContentProviderOperation;
import android.content.ContentProviderResult;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.IContentProvider;
import android.content.OperationApplicationException;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteQueryBuilder;
import android.net.Uri;
import android.os.Binder;
import android.provider.ContactsContract;
import android.provider.ContactsContract.MetadataSync;
import android.provider.ContactsContract.MetadataSyncState;
import android.text.TextUtils;
import android.util.Log;
import com.android.common.content.ProjectionMap;
import com.android.providers.contacts.ContactsDatabaseHelper.MetadataSyncColumns;
import com.android.providers.contacts.ContactsDatabaseHelper.MetadataSyncStateColumns;
import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
import com.android.providers.contacts.ContactsDatabaseHelper.Views;
import com.android.providers.contacts.MetadataEntryParser.MetadataEntry;
import com.android.providers.contacts.util.SelectionBuilder;
import com.android.providers.contacts.util.UserUtils;
import com.google.common.annotations.VisibleForTesting;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Map;

import static com.android.providers.contacts.ContactsProvider2.getLimit;
import static com.android.providers.contacts.util.DbQueryUtils.getEqualityClause;

/**
 * Simple content provider to handle directing contact metadata specific calls.
 */
public class ContactMetadataProvider extends ContentProvider {
    private static final String TAG = "ContactMetadata";
    private static final boolean VERBOSE_LOGGING = Log.isLoggable(TAG, Log.VERBOSE);
    private static final int METADATA_SYNC = 1;
    private static final int METADATA_SYNC_ID = 2;
    private static final int SYNC_STATE = 3;

    private static final UriMatcher sURIMatcher = new UriMatcher(UriMatcher.NO_MATCH);

    static {
        sURIMatcher.addURI(MetadataSync.METADATA_AUTHORITY, "metadata_sync", METADATA_SYNC);
        sURIMatcher.addURI(MetadataSync.METADATA_AUTHORITY, "metadata_sync/#", METADATA_SYNC_ID);
        sURIMatcher.addURI(MetadataSync.METADATA_AUTHORITY, "metadata_sync_state", SYNC_STATE);
    }

    private static final Map<String, String> sMetadataProjectionMap = ProjectionMap.builder()
            .add(MetadataSync._ID)
            .add(MetadataSync.RAW_CONTACT_BACKUP_ID)
            .add(MetadataSync.ACCOUNT_TYPE)
            .add(MetadataSync.ACCOUNT_NAME)
            .add(MetadataSync.DATA_SET)
            .add(MetadataSync.DATA)
            .add(MetadataSync.DELETED)
            .build();

    private static final Map<String, String> sSyncStateProjectionMap =ProjectionMap.builder()
            .add(MetadataSyncState._ID)
            .add(MetadataSyncState.ACCOUNT_TYPE)
            .add(MetadataSyncState.ACCOUNT_NAME)
            .add(MetadataSyncState.DATA_SET)
            .add(MetadataSyncState.STATE)
            .build();

    private ContactsDatabaseHelper mDbHelper;
    private ContactsProvider2 mContactsProvider;

    private String mAllowedPackage;

    @Override
    public boolean onCreate() {
        final Context context = getContext();
        mDbHelper = getDatabaseHelper(context);
        final IContentProvider iContentProvider = context.getContentResolver().acquireProvider(
                ContactsContract.AUTHORITY);
        final ContentProvider provider = ContentProvider.coerceToLocalContentProvider(
                iContentProvider);
        mContactsProvider = (ContactsProvider2) provider;

        mAllowedPackage = getContext().getResources().getString(R.string.metadata_sync_pacakge);
        return true;
    }

    protected ContactsDatabaseHelper getDatabaseHelper(final Context context) {
        return ContactsDatabaseHelper.getInstance(context);
    }

    @VisibleForTesting
    protected void setDatabaseHelper(final ContactsDatabaseHelper helper) {
        mDbHelper = helper;
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
            String sortOrder) {

        ensureCaller();

        if (VERBOSE_LOGGING) {
            Log.v(TAG, "query: uri=" + uri + "  projection=" + Arrays.toString(projection) +
                    "  selection=[" + selection + "]  args=" + Arrays.toString(selectionArgs) +
                    "  order=[" + sortOrder + "] CPID=" + Binder.getCallingPid() +
                    " User=" + UserUtils.getCurrentUserHandle(getContext()));
        }
        final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
        String limit = getLimit(uri);

        final SelectionBuilder selectionBuilder = new SelectionBuilder(selection);

        final int match = sURIMatcher.match(uri);
        switch (match) {
            case METADATA_SYNC:
                setTablesAndProjectionMapForMetadata(qb);
                break;

            case METADATA_SYNC_ID: {
                setTablesAndProjectionMapForMetadata(qb);
                selectionBuilder.addClause(getEqualityClause(MetadataSync._ID,
                        ContentUris.parseId(uri)));
                break;
            }

            case SYNC_STATE:
                setTablesAndProjectionMapForSyncState(qb);
                break;
            default:
                throw new IllegalArgumentException("Unknown URL " + uri);
        }

        final SQLiteDatabase db = mDbHelper.getReadableDatabase();
        return qb.query(db, projection, selectionBuilder.build(), selectionArgs, null,
                null, sortOrder, limit);
    }

    @Override
    public String getType(Uri uri) {
        int match = sURIMatcher.match(uri);
        switch (match) {
            case METADATA_SYNC:
                return MetadataSync.CONTENT_TYPE;
            case METADATA_SYNC_ID:
                return MetadataSync.CONTENT_ITEM_TYPE;
            case SYNC_STATE:
                return MetadataSyncState.CONTENT_TYPE;
            default:
                throw new IllegalArgumentException("Unknown URI: " + uri);
        }
    }

    @Override
    /**
     * Insert or update if the raw is already existing.
     */
    public Uri insert(Uri uri, ContentValues values) {

        ensureCaller();

        final SQLiteDatabase db = mDbHelper.getWritableDatabase();
        db.beginTransaction();
        try {
            final int matchedUriId = sURIMatcher.match(uri);
            switch (matchedUriId) {
                case METADATA_SYNC:
                    // Insert the new entry, and also parse the data column to update related
                    // tables.
                    final long metadataSyncId = updateOrInsertDataToMetadataSync(db, uri, values);
                    db.setTransactionSuccessful();
                    return ContentUris.withAppendedId(uri, metadataSyncId);
                case SYNC_STATE:
                    replaceAccountInfoByAccountId(uri, values);
                    final Long syncStateId = db.replace(
                            Tables.METADATA_SYNC_STATE, MetadataSyncColumns.ACCOUNT_ID, values);
                    db.setTransactionSuccessful();
                    return ContentUris.withAppendedId(uri, syncStateId);
                default:
                    throw new IllegalArgumentException(mDbHelper.exceptionMessage(
                            "Calling contact metadata insert on an unknown/invalid URI", uri));
            }
        } finally {
            db.endTransaction();
        }
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {

        ensureCaller();

        final SQLiteDatabase db = mDbHelper.getWritableDatabase();
        db.beginTransaction();
        try {
            final int matchedUriId = sURIMatcher.match(uri);
            int numDeletes = 0;
            switch (matchedUriId) {
                case METADATA_SYNC:
                    Cursor c = db.query(Views.METADATA_SYNC, new String[]{MetadataSync._ID},
                            selection, selectionArgs, null, null, null);
                    try {
                        while (c.moveToNext()) {
                            final long contactMetadataId = c.getLong(0);
                            numDeletes += db.delete(Tables.METADATA_SYNC,
                                    MetadataSync._ID + "=" + contactMetadataId, null);
                        }
                    } finally {
                        c.close();
                    }
                    db.setTransactionSuccessful();
                    return numDeletes;
                case SYNC_STATE:
                    c = db.query(Views.METADATA_SYNC_STATE, new String[]{MetadataSyncState._ID},
                            selection, selectionArgs, null, null, null);
                    try {
                        while (c.moveToNext()) {
                            final long stateId = c.getLong(0);
                            numDeletes += db.delete(Tables.METADATA_SYNC_STATE,
                                    MetadataSyncState._ID + "=" + stateId, null);
                        }
                    } finally {
                        c.close();
                    }
                    db.setTransactionSuccessful();
                    return numDeletes;
                default:
                    throw new IllegalArgumentException(mDbHelper.exceptionMessage(
                            "Calling contact metadata delete on an unknown/invalid URI", uri));
            }
        } finally {
            db.endTransaction();
        }
    }

    @Override
    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {

        ensureCaller();

        final SQLiteDatabase db = mDbHelper.getWritableDatabase();
        db.beginTransaction();
        try {
            final int matchedUriId = sURIMatcher.match(uri);
            switch (matchedUriId) {
                // Do not support update metadata sync by update() method. Please use insert().
                case SYNC_STATE:
                    // Only support update by account.
                    final Long accountId = replaceAccountInfoByAccountId(uri, values);
                    if (accountId == null) {
                        throw new IllegalArgumentException(mDbHelper.exceptionMessage(
                                "Invalid identifier is found for accountId", uri));
                    }
                    values.put(MetadataSyncColumns.ACCOUNT_ID, accountId);
                    // Insert a new row if it doesn't exist.
                    db.replace(Tables.METADATA_SYNC_STATE, null, values);
                    db.setTransactionSuccessful();
                    return 1;
                default:
                    throw new IllegalArgumentException(mDbHelper.exceptionMessage(
                            "Calling contact metadata update on an unknown/invalid URI", uri));
            }
        } finally {
            db.endTransaction();
        }
    }

    @Override
    public ContentProviderResult[] applyBatch(ArrayList<ContentProviderOperation> operations)
            throws OperationApplicationException {

        ensureCaller();

        if (VERBOSE_LOGGING) {
            Log.v(TAG, "applyBatch: " + operations.size() + " ops");
        }
        final SQLiteDatabase db = mDbHelper.getWritableDatabase();
        db.beginTransaction();
        try {
            ContentProviderResult[] results = super.applyBatch(operations);
            db.setTransactionSuccessful();
            return results;
        } finally {
            db.endTransaction();
        }
    }

    @Override
    public int bulkInsert(Uri uri, ContentValues[] values) {

        ensureCaller();

        if (VERBOSE_LOGGING) {
            Log.v(TAG, "bulkInsert: " + values.length + " inserts");
        }
        final SQLiteDatabase db = mDbHelper.getWritableDatabase();
        db.beginTransaction();
        try {
            final int numValues = super.bulkInsert(uri, values);
            db.setTransactionSuccessful();
            return numValues;
        } finally {
            db.endTransaction();
        }
    }

    private void setTablesAndProjectionMapForMetadata(SQLiteQueryBuilder qb){
        qb.setTables(Views.METADATA_SYNC);
        qb.setProjectionMap(sMetadataProjectionMap);
        qb.setStrict(true);
    }

    private void setTablesAndProjectionMapForSyncState(SQLiteQueryBuilder qb){
        qb.setTables(Views.METADATA_SYNC_STATE);
        qb.setProjectionMap(sSyncStateProjectionMap);
        qb.setStrict(true);
    }

    /**
     * Insert or update a non-deleted entry to MetadataSync table, and also parse the data column
     * to update related tables for the raw contact.
     * Returns new upserted metadataSyncId.
     */
    private long updateOrInsertDataToMetadataSync(SQLiteDatabase db, Uri uri, ContentValues values) {
        final int matchUri = sURIMatcher.match(uri);
        if (matchUri != METADATA_SYNC) {
            throw new IllegalArgumentException(mDbHelper.exceptionMessage(
                    "Calling contact metadata insert or update on an unknown/invalid URI", uri));
        }

        // Don't insert or update a deleted metadata.
        Integer deleted = values.getAsInteger(MetadataSync.DELETED);
        if (deleted != null && deleted != 0) {
            throw new IllegalArgumentException(mDbHelper.exceptionMessage(
                    "Cannot insert or update deleted metadata:" + values.toString(), uri));
        }

        // Check if data column is empty or null.
        final String data = values.getAsString(MetadataSync.DATA);
        if (TextUtils.isEmpty(data)) {
            throw new IllegalArgumentException(mDbHelper.exceptionMessage(
                    "Data column cannot be empty.", uri));
        }

        // Update or insert for backupId and account info.
        final Long accountId = replaceAccountInfoByAccountId(uri, values);
        final String rawContactBackupId = values.getAsString(
                MetadataSync.RAW_CONTACT_BACKUP_ID);
        // TODO (tingtingw): Consider a corner case: if there's raw with the same accountId and
        // backupId, but deleted=1, (Deleted should be synced up to server and hard-deleted, but
        // may be delayed.) In this case, should we not override it with delete=0? or should this
        // be prevented by sync adapter side?.
        deleted = 0; // Only insert or update non-deleted metadata
        if (accountId == null) {
            // Do nothing, just return.
            return 0;
        }
        if (rawContactBackupId == null) {
            throw new IllegalArgumentException(mDbHelper.exceptionMessage(
                    "Invalid identifier is found: accountId=" + accountId + "; " +
                            "rawContactBackupId=" + rawContactBackupId, uri));
        }

        // Update if it exists, otherwise insert.
        final long metadataSyncId = mDbHelper.upsertMetadataSync(
                rawContactBackupId, accountId, data, deleted);
        if (metadataSyncId <= 0) {
            throw new IllegalArgumentException(mDbHelper.exceptionMessage(
                    "Metadata upsertion failed. Values= " + values.toString(), uri));
        }

        // Parse the data column and update other tables.
        // Data field will never be empty or null, since contacts prefs and usage stats
        // have default values.
        final MetadataEntry metadataEntry = MetadataEntryParser.parseDataToMetaDataEntry(data);
        mContactsProvider.updateFromMetaDataEntry(db, metadataEntry);

        return metadataSyncId;
    }

    /**
     *  Replace account_type, account_name and data_set with account_id. If a valid account_id
     *  cannot be found for this combination, return null.
     */
    private Long replaceAccountInfoByAccountId(Uri uri, ContentValues values) {
        String accountName = values.getAsString(MetadataSync.ACCOUNT_NAME);
        String accountType = values.getAsString(MetadataSync.ACCOUNT_TYPE);
        String dataSet = values.getAsString(MetadataSync.DATA_SET);
        final boolean partialUri = TextUtils.isEmpty(accountName) ^ TextUtils.isEmpty(accountType);
        if (partialUri) {
            // Throw when either account is incomplete.
            throw new IllegalArgumentException(mDbHelper.exceptionMessage(
                    "Must specify both or neither of ACCOUNT_NAME and ACCOUNT_TYPE", uri));
        }

        final AccountWithDataSet account = AccountWithDataSet.get(
                accountName, accountType, dataSet);

        final Long id = mDbHelper.getAccountIdOrNull(account);
        if (id == null) {
            return null;
        }

        values.put(MetadataSyncColumns.ACCOUNT_ID, id);
        // Only remove the account information once the account ID is extracted (since these
        // fields are actually used by resolveAccountWithDataSet to extract the relevant ID).
        values.remove(MetadataSync.ACCOUNT_NAME);
        values.remove(MetadataSync.ACCOUNT_TYPE);
        values.remove(MetadataSync.DATA_SET);

        return id;
    }

    @VisibleForTesting
    void ensureCaller() {
        final String caller = getCallingPackage();
        if (mAllowedPackage.equals(caller)) {
            return; // Okay.
        }
        throw new SecurityException("Caller " + caller + " can't access ContactMetadataProvider");
    }
}
