/*
 * 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.messaging.datamodel;

import android.content.Context;
import android.content.pm.PackageManager;
import android.database.ContentObserver;
import android.net.Uri;
import android.provider.Telephony;
import androidx.collection.LongSparseArray;

import com.android.messaging.datamodel.action.SyncMessagesAction;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsUtils;
import com.android.messaging.util.Assert;
import com.android.messaging.util.BugleGservices;
import com.android.messaging.util.BugleGservicesKeys;
import com.android.messaging.util.BuglePrefs;
import com.android.messaging.util.BuglePrefsKeys;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.OsUtil;
import com.android.messaging.util.PhoneUtils;
import com.google.common.collect.Lists;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;

/**
 * This class manages message sync with the Telephony SmsProvider/MmsProvider.
 */
public class SyncManager {
    private static final String TAG = LogUtil.BUGLE_TAG;

    /**
     * Record of any user customization to conversation settings
     */
    public static class ConversationCustomization {
        private final boolean mArchived;
        private final boolean mMuted;
        private final boolean mNoVibrate;
        private final String mNotificationSoundUri;

        public ConversationCustomization(final boolean archived, final boolean muted,
                final boolean noVibrate, final String notificationSoundUri) {
            mArchived = archived;
            mMuted = muted;
            mNoVibrate = noVibrate;
            mNotificationSoundUri = notificationSoundUri;
        }

        public boolean isArchived() {
            return mArchived;
        }

        public boolean isMuted() {
            return mMuted;
        }

        public boolean noVibrate() {
            return mNoVibrate;
        }

        public String getNotificationSoundUri() {
            return mNotificationSoundUri;
        }
    }

    SyncManager() {
    }

    /**
     * Timestamp of in progress sync - used to keep track of whether sync is running
     */
    private long mSyncInProgressTimestamp = -1;

    /**
     * Timestamp of current sync batch upper bound - used to determine if message makes batch dirty
     */
    private long mCurrentUpperBoundTimestamp = -1;

    /**
     * Timestamp of messages inserted since sync batch started - used to determine if batch dirty
     */
    private long mMaxRecentChangeTimestamp = -1L;

    private final ThreadInfoCache mThreadInfoCache = new ThreadInfoCache();

    /**
     * User customization to conversations. If this is set, we need to recover them after
     * a full sync.
     */
    private LongSparseArray<ConversationCustomization> mCustomization = null;

    /**
     * Start an incremental sync (backed off a few seconds)
     */
    public static void sync() {
        SyncMessagesAction.sync();
    }

    /**
     * Start an incremental sync (with no backoff)
     */
    public static void immediateSync() {
        SyncMessagesAction.immediateSync();
    }

    /**
     * Start a full sync (for debugging)
     */
    public static void forceSync() {
        SyncMessagesAction.fullSync();
    }

    /**
     * Called from data model thread when starting a sync batch
     * @param upperBoundTimestamp upper bound timestamp for sync batch
     */
    public synchronized void startSyncBatch(final long upperBoundTimestamp) {
        Assert.isTrue(mCurrentUpperBoundTimestamp < 0);
        mCurrentUpperBoundTimestamp = upperBoundTimestamp;
        mMaxRecentChangeTimestamp = -1L;
    }

    /**
     * Called from data model thread at end of batch to determine if any messages added in window
     * @param lowerBoundTimestamp lower bound timestamp for sync batch
     * @return true if message added within window from lower to upper bound timestamp of batch
     */
    public synchronized boolean isBatchDirty(final long lowerBoundTimestamp) {
        Assert.isTrue(mCurrentUpperBoundTimestamp >= 0);
        final long max = mMaxRecentChangeTimestamp;

        final boolean dirty = (max >= 0 && max >= lowerBoundTimestamp);
        if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
            LogUtil.d(TAG, "SyncManager: Sync batch of messages from " + lowerBoundTimestamp
                    + " to " + mCurrentUpperBoundTimestamp + " is "
                    + (dirty ? "DIRTY" : "clean") + "; max change timestamp = "
                    + mMaxRecentChangeTimestamp);
        }

        mCurrentUpperBoundTimestamp = -1L;
        mMaxRecentChangeTimestamp = -1L;

        return dirty;
    }

    /**
     * Called from data model or background worker thread to indicate start of message add process
     * (add must complete on that thread before action transitions to new thread/stage)
     * @param timestamp timestamp of message being added
     */
    public synchronized void onNewMessageInserted(final long timestamp) {
        if (mCurrentUpperBoundTimestamp >= 0 && timestamp <= mCurrentUpperBoundTimestamp) {
            // Message insert in current sync window
            mMaxRecentChangeTimestamp = Math.max(mCurrentUpperBoundTimestamp, timestamp);
            if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
                LogUtil.d(TAG, "SyncManager: New message @ " + timestamp + " before upper bound of "
                        + "current sync batch " + mCurrentUpperBoundTimestamp);
            }
        } else if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
            LogUtil.d(TAG, "SyncManager: New message @ " + timestamp + " after upper bound of "
                    + "current sync batch " + mCurrentUpperBoundTimestamp);
        }
    }

    /**
     * Synchronously checks whether sync is allowed and starts sync if allowed
     * @param full - true indicates a full (not incremental) sync operation
     * @param startTimestamp - starttimestamp for this sync (if allowed)
     * @return - true if sync should start
     */
    public synchronized boolean shouldSync(final boolean full, final long startTimestamp) {
        if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
            LogUtil.v(TAG, "SyncManager: Checking shouldSync " + (full ? "full " : "")
                    + "at " + startTimestamp);
        }

        if (full) {
            final long delayUntilFullSync = delayUntilFullSync(startTimestamp);
            if (delayUntilFullSync > 0) {
                if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
                    LogUtil.d(TAG, "SyncManager: Full sync requested for " + startTimestamp
                            + " delayed for " + delayUntilFullSync + " ms");
                }
                return false;
            }
        }

        if (isSyncing()) {
            if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
                LogUtil.d(TAG, "SyncManager: Not allowed to " + (full ? "full " : "")
                        + "sync yet; still running sync started at " + mSyncInProgressTimestamp);
            }
            return false;
        }
        if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
            LogUtil.d(TAG, "SyncManager: Starting " + (full ? "full " : "") + "sync at "
                    + startTimestamp);
        }

        mSyncInProgressTimestamp = startTimestamp;

        return true;
    }

    /**
     * Return delay (in ms) until allowed to run a full sync (0 meaning can run immediately)
     * @param startTimestamp Timestamp used to start the sync
     * @return 0 if allowed to run now, else delay in ms
     */
    public long delayUntilFullSync(final long startTimestamp) {
        final BugleGservices bugleGservices = BugleGservices.get();
        final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();

        final long lastFullSyncTime = prefs.getLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, -1L);
        final long smsFullSyncBackoffTimeMillis = bugleGservices.getLong(
                BugleGservicesKeys.SMS_FULL_SYNC_BACKOFF_TIME_MILLIS,
                BugleGservicesKeys.SMS_FULL_SYNC_BACKOFF_TIME_MILLIS_DEFAULT);
        final long noFullSyncBefore = (lastFullSyncTime < 0 ? startTimestamp :
            lastFullSyncTime + smsFullSyncBackoffTimeMillis);

        final long delayUntilFullSync = noFullSyncBefore - startTimestamp;
        if (delayUntilFullSync > 0) {
            return delayUntilFullSync;
        }
        return 0;
    }

    /**
     * Check if sync currently in progress (public for asserts/logging).
     */
    public synchronized boolean isSyncing() {
        return (mSyncInProgressTimestamp >= 0);
    }

    /**
     * Check if sync batch should be in progress - compares upperBound with in memory value
     * @param upperBoundTimestamp - upperbound timestamp for sync batch
     * @return - true if timestamps match (otherwise batch is orphan from older process)
     */
    public synchronized boolean isSyncing(final long upperBoundTimestamp) {
        Assert.isTrue(upperBoundTimestamp >= 0);
        return (upperBoundTimestamp == mCurrentUpperBoundTimestamp);
    }

    /**
     * Check if sync has completed for the first time.
     */
    public boolean getHasFirstSyncCompleted() {
        final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
        return prefs.getLong(BuglePrefsKeys.LAST_SYNC_TIME,
                BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT) !=
                BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT;
    }

    /**
     * Called once sync is complete
     */
    public synchronized void complete() {
        if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
            LogUtil.d(TAG, "SyncManager: Sync started at " + mSyncInProgressTimestamp
                    + " marked as complete");
        }
        mSyncInProgressTimestamp = -1L;
        // Conversation customization only used once
        mCustomization = null;
    }

    private final ContentObserver mMmsSmsObserver = new TelephonyMessagesObserver();
    private boolean mSyncOnChanges = false;
    private boolean mNotifyOnChanges = false;

    /**
     * Register content observer when necessary and kick off a catch up sync
     */
    public void updateSyncObserver(final Context context) {
        registerObserver(context);
        // Trigger an sms sync in case we missed and messages before registering this observer or
        // becoming the SMS provider.
        immediateSync();
    }

    private void registerObserver(final Context context) {
        if (!PhoneUtils.getDefault().isDefaultSmsApp()) {
            // Not default SMS app - need to actively monitor telephony but not notify
            mNotifyOnChanges = false;
            mSyncOnChanges = true;
        } else if (OsUtil.isSecondaryUser()){
            // Secondary users default SMS app - need to actively monitor telephony and notify
            mNotifyOnChanges = true;
            mSyncOnChanges = true;
        } else {
            // Primary users default SMS app - don't monitor telephony (most changes from this app)
            mNotifyOnChanges = false;
            mSyncOnChanges = false;
            if (context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)) {
                // This is default SMS app but on Auto platform, even default SMS app needs
                // to be notified of changes because Bluetooth makes changes to Sms database
                // (thru ContentProvider). Content Change notifications help the default SMS app
                // refresh display with changes, whenever Bluetooth changes data in SMS db.
                mNotifyOnChanges = true;
                mSyncOnChanges = false;
            }
        }
        if (mNotifyOnChanges || mSyncOnChanges) {
            context.getContentResolver().registerContentObserver(Telephony.MmsSms.CONTENT_URI,
                    true, mMmsSmsObserver);
        } else {
            context.getContentResolver().unregisterContentObserver(mMmsSmsObserver);
        }
    }

    public synchronized void setCustomization(
            final LongSparseArray<ConversationCustomization> customization) {
        this.mCustomization = customization;
    }

    public synchronized ConversationCustomization getCustomizationForThread(final long threadId) {
        if (mCustomization != null) {
            return mCustomization.get(threadId);
        }
        return null;
    }

    public static void resetLastSyncTimestamps() {
        final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
        prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME,
                BuglePrefsKeys.LAST_FULL_SYNC_TIME_DEFAULT);
        prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT);
    }

    private class TelephonyMessagesObserver extends ContentObserver {
        public TelephonyMessagesObserver() {
            // Just run on default thread
            super(null);
        }

        // Implement the onChange(boolean) method to delegate the change notification to
        // the onChange(boolean, Uri) method to ensure correct operation on older versions
        // of the framework that did not have the onChange(boolean, Uri) method.
        @Override
        public void onChange(final boolean selfChange) {
            onChange(selfChange, null);
        }

        // Implement the onChange(boolean, Uri) method to take advantage of the new Uri argument.
        @Override
        public void onChange(final boolean selfChange, final Uri uri) {
            // Handle change.
            if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
                LogUtil.v(TAG, "SyncManager: Sms/Mms DB changed @" + System.currentTimeMillis()
                        + " for " + (uri == null ? "<unk>" : uri.toString()) + " "
                        + mSyncOnChanges + "/" + mNotifyOnChanges);
            }

            if (mSyncOnChanges) {
                // If sync is already running this will do nothing - but at end of each sync
                // action there is a check for recent messages that should catch new changes.
                SyncManager.immediateSync();
            }
            if (mNotifyOnChanges) {
                // TODO: Secondary users are not going to get notifications
            }
        }
    }

    public ThreadInfoCache getThreadInfoCache() {
        return mThreadInfoCache;
    }

    public static class ThreadInfoCache {
        // Cache of thread->conversationId map
        private final LongSparseArray<String> mThreadToConversationId =
                new LongSparseArray<String>();

        // Cache of thread->recipients map
        private final LongSparseArray<List<String>> mThreadToRecipients =
                new LongSparseArray<List<String>>();

        // Remember the conversation ids that need to be archived
        private final HashSet<String> mArchivedConversations = new HashSet<>();

        public synchronized void clear() {
            if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
                LogUtil.d(TAG, "SyncManager: Cleared ThreadInfoCache");
            }
            mThreadToConversationId.clear();
            mThreadToRecipients.clear();
            mArchivedConversations.clear();
        }

        public synchronized boolean isArchived(final String conversationId) {
            return mArchivedConversations.contains(conversationId);
        }

        /**
         * Get or create a conversation based on the message's thread id
         *
         * @param threadId The message's thread
         * @param refSubId The subId used for normalizing phone numbers in the thread
         * @param customization The user setting customization to the conversation if any
         * @return The existing conversation id or new conversation id
         */
        public synchronized String getOrCreateConversation(final DatabaseWrapper db,
                final long threadId, int refSubId, final ConversationCustomization customization) {
            // This function has several components which need to be atomic.
            Assert.isTrue(db.getDatabase().inTransaction());

            // If we already have this conversation ID in our local map, just return it
            String conversationId = mThreadToConversationId.get(threadId);
            if (conversationId != null) {
                return conversationId;
            }

            final List<String> recipients = getThreadRecipients(threadId);
            final ArrayList<ParticipantData> participants =
                    BugleDatabaseOperations.getConversationParticipantsFromRecipients(recipients,
                            refSubId);

            if (customization != null) {
                // There is user customization we need to recover
                conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId,
                        customization.isArchived(), participants, customization.isMuted(),
                        customization.noVibrate(), customization.getNotificationSoundUri());
                if (customization.isArchived()) {
                    mArchivedConversations.add(conversationId);
                }
            } else {
                conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId,
                        false/*archived*/, participants, false/*noNotification*/,
                        false/*noVibrate*/, null/*soundUri*/);
            }

            if (conversationId != null) {
                mThreadToConversationId.put(threadId, conversationId);
                return conversationId;
            }

            return null;
        }


        /**
         * Load the recipients of a thread from telephony provider. If we fail, use
         * a predefined unknown recipient. This should not return null.
         *
         * @param threadId
         */
        public synchronized List<String> getThreadRecipients(final long threadId) {
            List<String> recipients = mThreadToRecipients.get(threadId);
            if (recipients == null) {
                recipients = MmsUtils.getRecipientsByThread(threadId);
                if (recipients != null && recipients.size() > 0) {
                    mThreadToRecipients.put(threadId, recipients);
                }
            }

            if (recipients == null || recipients.isEmpty()) {
                LogUtil.w(TAG, "SyncManager : using unknown sender since thread " + threadId +
                        " couldn't find any recipients.");

                // We want to try our best to load the messages,
                // so if recipient info is broken, try to fix it with unknown recipient
                recipients = Lists.newArrayList();
                recipients.add(ParticipantData.getUnknownSenderDestination());
            }

            return recipients;
        }
    }
}
