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

import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.ConnectivityManager;
import android.os.Parcel;
import android.os.Parcelable;
import android.telephony.ServiceState;
import android.telephony.SubscriptionInfo;

import com.android.messaging.Factory;
import com.android.messaging.datamodel.BugleDatabaseOperations;
import com.android.messaging.datamodel.DataModel;
import com.android.messaging.datamodel.DatabaseHelper;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.datamodel.data.MessageData;
import com.android.messaging.datamodel.data.ParticipantData;
import com.android.messaging.sms.MmsUtils;
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.ConnectivityUtil.ConnectivityListener;
import com.android.messaging.util.LogUtil;
import com.android.messaging.util.OsUtil;
import com.android.messaging.util.PhoneUtils;

import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * Action used to lookup any messages in the pending send/download state and either fail them or
 * retry their action. This action only initiates one retry at a time - further retries should be
 * triggered by successful sending of a message, network status change or exponential backoff timer.
 */
public class ProcessPendingMessagesAction extends Action implements Parcelable {
    private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG;
    private static final int PENDING_INTENT_REQUEST_CODE = 101;

    public static void processFirstPendingMessage() {
        // Clear any pending alarms or connectivity events
        unregister();
        // Clear retry count
        setRetry(0);

        // Start action
        final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
        action.start();
    }

    public static void scheduleProcessPendingMessagesAction(final boolean failed,
            final Action processingAction) {
        LogUtil.i(TAG, "ProcessPendingMessagesAction: Scheduling pending messages"
                + (failed ? "(message failed)" : ""));
        // Can safely clear any pending alarms or connectivity events as either an action
        // is currently running or we will run now or register if pending actions possible.
        unregister();

        final boolean isDefaultSmsApp = PhoneUtils.getDefault().isDefaultSmsApp();
        boolean scheduleAlarm = false;
        // If message succeeded and if Bugle is default SMS app just carry on with next message
        if (!failed && isDefaultSmsApp) {
            // Clear retry attempt count as something just succeeded
            setRetry(0);

            // Lookup and queue next message for immediate processing by background worker
            //  iff there are no pending messages this will do nothing and return true.
            final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
            if (action.queueActions(processingAction)) {
                if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
                    if (processingAction.hasBackgroundActions()) {
                        LogUtil.v(TAG, "ProcessPendingMessagesAction: Action queued");
                    } else {
                        LogUtil.v(TAG, "ProcessPendingMessagesAction: No actions to queue");
                    }
                }
                // Have queued next action if needed, nothing more to do
                return;
            }
            // In case of error queuing schedule a retry
            scheduleAlarm = true;
            LogUtil.w(TAG, "ProcessPendingMessagesAction: Action failed to queue; retrying");
        }
        if (getHavePendingMessages() || scheduleAlarm) {
            // Still have a pending message that needs to be queued for processing
            final ConnectivityListener listener = new ConnectivityListener() {
                @Override
                public void onConnectivityStateChanged(final Context context, final Intent intent) {
                    final int networkType =
                            MmsUtils.getConnectivityEventNetworkType(context, intent);
                    if (networkType != ConnectivityManager.TYPE_MOBILE) {
                        return;
                    }
                    final boolean isConnected = !intent.getBooleanExtra(
                            ConnectivityManager.EXTRA_NO_CONNECTIVITY, false);
                    // TODO: Should we check in more detail?
                    if (isConnected) {
                        onConnected();
                    }
                }

                @Override
                public void onPhoneStateChanged(final Context context, final int serviceState) {
                    if (serviceState == ServiceState.STATE_IN_SERVICE) {
                        onConnected();
                    }
                }

                private void onConnected() {
                    LogUtil.i(TAG, "ProcessPendingMessagesAction: Now connected; starting action");

                    // Clear any pending alarms or connectivity events but leave attempt count alone
                    unregister();

                    // Start action
                    final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
                    action.start();
                }
            };
            // Read and increment attempt number from shared prefs
            final int retryAttempt = getNextRetry();
            register(listener, retryAttempt);
        } else {
            // No more pending messages (presumably the message that failed has expired) or it
            // may be possible that a send and a download are already in process.
            // Clear retry attempt count.
            // TODO Might be premature if send and download in process...
            //  but worst case means we try to send a bit more often.
            setRetry(0);

            if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
                LogUtil.v(TAG, "ProcessPendingMessagesAction: No more pending messages");
            }
        }
    }

    private static void register(final ConnectivityListener listener, final int retryAttempt) {
        int retryNumber = retryAttempt;

        // Register to be notified about connectivity changes
        DataModel.get().getConnectivityUtil().register(listener);

        final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
        final long initialBackoffMs = BugleGservices.get().getLong(
                BugleGservicesKeys.INITIAL_MESSAGE_RESEND_DELAY_MS,
                BugleGservicesKeys.INITIAL_MESSAGE_RESEND_DELAY_MS_DEFAULT);
        final long maxDelayMs = BugleGservices.get().getLong(
                BugleGservicesKeys.MAX_MESSAGE_RESEND_DELAY_MS,
                BugleGservicesKeys.MAX_MESSAGE_RESEND_DELAY_MS_DEFAULT);
        long delayMs;
        long nextDelayMs = initialBackoffMs;
        do {
            delayMs = nextDelayMs;
            retryNumber--;
            nextDelayMs = delayMs * 2;
        }
        while (retryNumber > 0 && nextDelayMs < maxDelayMs);

        LogUtil.i(TAG, "ProcessPendingMessagesAction: Registering for retry #" + retryAttempt
                + " in " + delayMs + " ms");

        action.schedule(PENDING_INTENT_REQUEST_CODE, delayMs);
    }

    private static void unregister() {
        // Clear any pending alarms or connectivity events
        DataModel.get().getConnectivityUtil().unregister();

        final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction();
        action.schedule(PENDING_INTENT_REQUEST_CODE, Long.MAX_VALUE);

        if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
            LogUtil.v(TAG, "ProcessPendingMessagesAction: Unregistering for connectivity changed "
                    + "events and clearing scheduled alarm");
        }
    }

    private static void setRetry(final int retryAttempt) {
        final BuglePrefs prefs = Factory.get().getApplicationPrefs();
        prefs.putInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, retryAttempt);
    }

    private static int getNextRetry() {
        final BuglePrefs prefs = Factory.get().getApplicationPrefs();
        final int retryAttempt =
                prefs.getInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, 0) + 1;
        prefs.putInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, retryAttempt);
        return retryAttempt;
    }

    private ProcessPendingMessagesAction() {
    }

    /**
     * Read from the DB and determine if there are any messages we should process
     * @return true if we have pending messages
     */
    private static boolean getHavePendingMessages() {
        final DatabaseWrapper db = DataModel.get().getDatabase();
        final long now = System.currentTimeMillis();

        for (int subId : getActiveSubscriptionIds()) {
            final String toSendMessageId = findNextMessageToSend(db, now, subId);
            if (toSendMessageId != null) {
                return true;
            } else {
                final String toDownloadMessageId = findNextMessageToDownload(db, now, subId);
                if (toDownloadMessageId != null) {
                    return true;
                }
            }
        }
        // Messages may be in the process of sending/downloading even when there are no pending
        // messages...
        return false;
    }

    private static int[] getActiveSubscriptionIds() {
        if (!OsUtil.isAtLeastL_MR1()) {
            return new int[] { ParticipantData.DEFAULT_SELF_SUB_ID };
        }
        List<SubscriptionInfo> subscriptions = PhoneUtils.getDefault().toLMr1()
            .getActiveSubscriptionInfoList();

        int numSubs = subscriptions.size();
        int[] result = new int[numSubs];
        for (int i = 0; i < numSubs; i++) {
            result[i] = subscriptions.get(i).getSubscriptionId();
        }
        return result;
    }

    /**
     * Queue any pending actions
     * @param actionState
     * @return true if action queued (or no actions to queue) else false
     */
    private boolean queueActions(final Action processingAction) {
        final DatabaseWrapper db = DataModel.get().getDatabase();
        final long now = System.currentTimeMillis();
        boolean succeeded = false;

        // Will queue no more than one message per subscription to send plus one message to download
        // This keeps outgoing messages "in order" but allow downloads to happen even if sending
        //  gets blocked until messages time out.  Manual resend bumps messages to head of queue.
        for (int subId : getActiveSubscriptionIds()) {
            final String toSendMessageId = findNextMessageToSend(db, now, subId);
            final String toDownloadMessageId = findNextMessageToDownload(db, now, subId);
            if (toSendMessageId != null) {
                LogUtil.i(TAG, "ProcessPendingMessagesAction: Queueing message " + toSendMessageId
                        + " for sending");
                // This could queue nothing
                if (!SendMessageAction.queueForSendInBackground(toSendMessageId,
                            processingAction)) {
                    LogUtil.w(TAG, "ProcessPendingMessagesAction: Failed to queue message "
                            + toSendMessageId + " for sending");
                } else {
                    succeeded = true;
                }
            }
            if (toDownloadMessageId != null) {
                LogUtil.i(TAG, "ProcessPendingMessagesAction: Queueing message "
                        + toDownloadMessageId + " for download");
                // This could queue nothing
                if (!DownloadMmsAction.queueMmsForDownloadInBackground(toDownloadMessageId,
                            processingAction)) {
                    LogUtil.w(TAG, "ProcessPendingMessagesAction: Failed to queue message "
                            + toDownloadMessageId + " for download");
                } else {
                    succeeded = true;
                }

            }
            if (toSendMessageId == null && toDownloadMessageId == null) {
                if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
                    LogUtil.d(TAG, "ProcessPendingMessagesAction: No messages to send or download");
                }
                succeeded = true;
            }
        }
        return succeeded;
    }

    @Override
    protected Object executeAction() {
        // If triggered by alarm will not have unregistered yet
        unregister();

        if (PhoneUtils.getDefault().isDefaultSmsApp()) {
            queueActions(this);
        } else {
            if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
                LogUtil.v(TAG, "ProcessPendingMessagesAction: Not default SMS app; rescheduling");
            }
            scheduleProcessPendingMessagesAction(true, this);
        }

        return null;
    }

    private static String prefixColumnWithTable(final  String tableName, final String column) {
        return tableName + "." + column;
    }

    private static String[] prefixProjectionWithTable(final String tableName,
            final String[] projection) {
        String[] result = new String[projection.length];
        for (int i = 0; i < projection.length; i++) {
            result[i] = prefixColumnWithTable(tableName, projection[i]);
        }
        return result;
    }

    private static String findNextMessageToSend(final DatabaseWrapper db, final long now,
            final int subId) {
        String toSendMessageId = null;
        db.beginTransaction();
        Cursor sending = null;
        Cursor cursor = null;
        int sendingCnt = 0;
        int pendingCnt = 0;
        int failedCnt = 0;
        try {
            String[] projection = prefixProjectionWithTable(DatabaseHelper.MESSAGES_TABLE,
                    MessageData.getProjection());
            String subIdClause =
                    prefixColumnWithTable(DatabaseHelper.MESSAGES_TABLE,
                            MessageColumns.SELF_PARTICIPANT_ID)
                    + " = "
                    + prefixColumnWithTable(DatabaseHelper.PARTICIPANTS_TABLE,
                            ParticipantColumns._ID)
                    + " AND " + ParticipantColumns.SUB_ID + " =?";

            // First check to see if we have any messages already sending
            sending = db.query(DatabaseHelper.MESSAGES_TABLE + ","
                            + DatabaseHelper.PARTICIPANTS_TABLE,
                            projection,
                    DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)"
                            + " AND " + subIdClause,
                    new String[]{Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_SENDING),
                           Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_RESENDING),
                           Integer.toString(subId)},
                    null,
                    null,
                    DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC");
            final boolean messageCurrentlySending = sending.moveToNext();
            sendingCnt = sending.getCount();
            // Look for messages we could send
            final ContentValues values = new ContentValues();
            values.put(DatabaseHelper.MessageColumns.STATUS,
                    MessageData.BUGLE_STATUS_OUTGOING_FAILED);
            cursor = db.query(DatabaseHelper.MESSAGES_TABLE + ","
                            + DatabaseHelper.PARTICIPANTS_TABLE,
                    projection,
                    DatabaseHelper.MessageColumns.STATUS + " IN ("
                            + MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND + ","
                            + MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY + ")"
                            + " AND " + subIdClause,
                    new String[]{Integer.toString(subId)},
                    null,
                    null,
                    DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC");
            pendingCnt = cursor.getCount();

            while (cursor.moveToNext()) {
                final MessageData message = new MessageData();
                message.bind(cursor);
                if (message.getInResendWindow(now)) {
                    // If no messages currently sending
                    if (!messageCurrentlySending) {
                        // Resend this message
                        toSendMessageId = message.getMessageId();
                        // Before queuing the message for resending, check if the message's self is
                        // active. If not, switch back to the system's default subscription.
                        if (OsUtil.isAtLeastL_MR1()) {
                            final ParticipantData messageSelf = BugleDatabaseOperations
                                    .getExistingParticipant(db, message.getSelfId());
                            if (messageSelf == null || !messageSelf.isActiveSubscription()) {
                                final ParticipantData defaultSelf = BugleDatabaseOperations
                                        .getOrCreateSelf(db, PhoneUtils.getDefault()
                                                .getDefaultSmsSubscriptionId());
                                if (defaultSelf != null) {
                                    message.bindSelfId(defaultSelf.getId());
                                    final ContentValues selfValues = new ContentValues();
                                    selfValues.put(MessageColumns.SELF_PARTICIPANT_ID,
                                            defaultSelf.getId());
                                    BugleDatabaseOperations.updateMessageRow(db,
                                            message.getMessageId(), selfValues);
                                    MessagingContentProvider.notifyMessagesChanged(
                                            message.getConversationId());
                                }
                            }
                        }
                    }
                    break;
                } else {
                    failedCnt++;

                    // Mark message as failed
                    BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values);
                    MessagingContentProvider.notifyMessagesChanged(message.getConversationId());
                }
            }
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
            if (cursor != null) {
                cursor.close();
            }
            if (sending != null) {
                sending.close();
            }
        }

        if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
            LogUtil.d(TAG, "ProcessPendingMessagesAction: "
                    + sendingCnt + " messages already sending, "
                    + pendingCnt + " messages to send, "
                    + failedCnt + " failed messages");
        }

        return toSendMessageId;
    }

    private static String findNextMessageToDownload(final DatabaseWrapper db, final long now,
            final int subId) {
        String toDownloadMessageId = null;
        db.beginTransaction();
        Cursor cursor = null;
        int downloadingCnt = 0;
        int pendingCnt = 0;
        try {
            String[] projection = prefixProjectionWithTable(DatabaseHelper.MESSAGES_TABLE,
                    MessageData.getProjection());
            String subIdClause =
                    prefixColumnWithTable(DatabaseHelper.MESSAGES_TABLE,
                            MessageColumns.SELF_PARTICIPANT_ID)
                    + " = "
                    + prefixColumnWithTable(DatabaseHelper.PARTICIPANTS_TABLE,
                            ParticipantColumns._ID)
                    + " AND " + ParticipantColumns.SUB_ID + " =?";

            // First check if we have any messages already downloading
            downloadingCnt = (int) db.queryNumEntries(DatabaseHelper.MESSAGES_TABLE
                    + "," + DatabaseHelper.PARTICIPANTS_TABLE,
                    DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)"
                            + " AND " + subIdClause,
                    new String[] {
                        Integer.toString(MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING),
                        Integer.toString(MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING),
                        Integer.toString(subId)
                    });

            // TODO: This query is not actually needed if downloadingCnt == 0.
            cursor = db.query(DatabaseHelper.MESSAGES_TABLE + ","
                    + DatabaseHelper.PARTICIPANTS_TABLE,
                    projection,
                    DatabaseHelper.MessageColumns.STATUS + " =? OR "
                            + DatabaseHelper.MessageColumns.STATUS + " =?"
                            + " AND " + subIdClause,
                    new String[]{
                            Integer.toString(
                                    MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD),
                            Integer.toString(
                                    MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD),
                            Integer.toString(
                                    subId)
                    },
                    null,
                    null,
                    DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC");

            pendingCnt = cursor.getCount();

            // If no messages are currently downloading and there is a download pending,
            // queue the download of the oldest pending message.
            if (downloadingCnt == 0 && cursor.moveToNext()) {
                // Always start the next pending message. We will check if a download has
                // expired in DownloadMmsAction and mark message failed there.
                final MessageData message = new MessageData();
                message.bind(cursor);
                toDownloadMessageId = message.getMessageId();
            }
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
            if (cursor != null) {
                cursor.close();
            }
        }

        if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) {
            LogUtil.d(TAG, "ProcessPendingMessagesAction: "
                    + downloadingCnt + " messages already downloading, "
                    + pendingCnt + " messages to download");
        }

        return toDownloadMessageId;
    }

    private ProcessPendingMessagesAction(final Parcel in) {
        super(in);
    }

    public static final Parcelable.Creator<ProcessPendingMessagesAction> CREATOR
            = new Parcelable.Creator<ProcessPendingMessagesAction>() {
        @Override
        public ProcessPendingMessagesAction createFromParcel(final Parcel in) {
            return new ProcessPendingMessagesAction(in);
        }

        @Override
        public ProcessPendingMessagesAction[] newArray(final int size) {
            return new ProcessPendingMessagesAction[size];
        }
    };

    @Override
    public void writeToParcel(final Parcel parcel, final int flags) {
        writeActionToParcel(parcel, flags);
    }
}
