/*
 * Copyright (C) 2016 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.server.notification;

import android.annotation.NonNull;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.Uri;
import android.os.Binder;
import android.os.UserHandle;
import android.service.notification.StatusBarNotification;
import android.util.ArrayMap;
import android.util.IntArray;
import android.util.Log;
import android.util.Slog;

import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto;
import com.android.modules.utils.TypedXmlPullParser;
import com.android.modules.utils.TypedXmlSerializer;
import com.android.server.pm.PackageManagerService;

import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

/**
 * NotificationManagerService helper for handling snoozed notifications.
 */
public final class SnoozeHelper {
    public static final int XML_SNOOZED_NOTIFICATION_VERSION = 1;

    static final int CONCURRENT_SNOOZE_LIMIT = 500;

    // A safe size for strings to be put in persistent storage, to avoid breaking the XML write.
    static final int MAX_STRING_LENGTH = 1000;

    protected static final String XML_TAG_NAME = "snoozed-notifications";

    private static final String XML_SNOOZED_NOTIFICATION = "notification";
    private static final String XML_SNOOZED_NOTIFICATION_CONTEXT = "context";
    private static final String XML_SNOOZED_NOTIFICATION_KEY = "key";
    //the time the snoozed notification should be reposted
    private static final String XML_SNOOZED_NOTIFICATION_TIME = "time";
    private static final String XML_SNOOZED_NOTIFICATION_CONTEXT_ID = "id";
    private static final String XML_SNOOZED_NOTIFICATION_VERSION_LABEL = "version";


    private static final String TAG = "SnoozeHelper";
    private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
    private static final String INDENT = "    ";

    private static final String REPOST_ACTION = SnoozeHelper.class.getSimpleName() + ".EVALUATE";
    private static final int REQUEST_CODE_REPOST = 1;
    private static final String REPOST_SCHEME = "repost";
    static final String EXTRA_KEY = "key";
    private static final String EXTRA_USER_ID = "userId";

    private final Context mContext;
    private AlarmManager mAm;
    private final ManagedServices.UserProfiles mUserProfiles;

    // notification key : record.
    private ArrayMap<String, NotificationRecord> mSnoozedNotifications = new ArrayMap<>();
    // notification key : time-milliseconds .
    // This member stores persisted snoozed notification trigger times. it persists through reboots
    // It should have the notifications that haven't expired or re-posted yet
    private final ArrayMap<String, Long> mPersistedSnoozedNotifications = new ArrayMap<>();
    // notification key : creation ID.
    // This member stores persisted snoozed notification trigger context for the assistant
    // it persists through reboots.
    // It should have the notifications that haven't expired or re-posted yet
    private final ArrayMap<String, String>
            mPersistedSnoozedNotificationsWithContext = new ArrayMap<>();

    private Callback mCallback;

    private final Object mLock = new Object();

    public SnoozeHelper(Context context, Callback callback,
            ManagedServices.UserProfiles userProfiles) {
        mContext = context;
        IntentFilter filter = new IntentFilter(REPOST_ACTION);
        filter.addDataScheme(REPOST_SCHEME);
        mContext.registerReceiver(mBroadcastReceiver, filter,
                Context.RECEIVER_EXPORTED_UNAUDITED);
        mAm = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);
        mCallback = callback;
        mUserProfiles = userProfiles;
    }

    protected boolean canSnooze(int numberToSnooze) {
        synchronized (mLock) {
            if ((mSnoozedNotifications.size() + numberToSnooze) > CONCURRENT_SNOOZE_LIMIT
                    || (mPersistedSnoozedNotifications.size()
                    + mPersistedSnoozedNotificationsWithContext.size() + numberToSnooze)
                    > CONCURRENT_SNOOZE_LIMIT) {
                return false;
            }
        }
        return true;
    }

    @NonNull
    protected Long getSnoozeTimeForUnpostedNotification(int userId, String pkg, String key) {
        Long time = null;
        synchronized (mLock) {
            time = mPersistedSnoozedNotifications.get(getTrimmedString(key));
        }
        if (time == null) {
            time = 0L;
        }
        return time;
    }

    protected String getSnoozeContextForUnpostedNotification(int userId, String pkg, String key) {
        synchronized (mLock) {
            return mPersistedSnoozedNotificationsWithContext.get(getTrimmedString(key));
        }
    }

    protected boolean isSnoozed(int userId, String pkg, String key) {
        synchronized (mLock) {
            return mSnoozedNotifications.containsKey(key);
        }
    }

    protected Collection<NotificationRecord> getSnoozed(int userId, String pkg) {
        synchronized (mLock) {
            ArrayList snoozed = new ArrayList();
            for (NotificationRecord r : mSnoozedNotifications.values()) {
                if (r.getUserId() == userId && r.getSbn().getPackageName().equals(pkg)) {
                    snoozed.add(r);
                }
            }
            return snoozed;
        }
    }

    @NonNull
    ArrayList<NotificationRecord> getNotifications(String pkg,
            String groupKey, Integer userId) {
        ArrayList<NotificationRecord> records =  new ArrayList<>();
        synchronized (mLock) {
            for (int i = 0; i < mSnoozedNotifications.size(); i++) {
                NotificationRecord r = mSnoozedNotifications.valueAt(i);
                if (r.getSbn().getPackageName().equals(pkg) && r.getUserId() == userId
                        && Objects.equals(r.getSbn().getGroup(), groupKey)) {
                    records.add(r);
                }
            }
        }
        return records;
    }

    protected NotificationRecord getNotification(String key) {
        synchronized (mLock) {
            return mSnoozedNotifications.get(key);
        }
    }

    protected @NonNull List<NotificationRecord> getSnoozed() {
        synchronized (mLock) {
            // caller filters records based on the current user profiles and listener access,
            // so just return everything
            List<NotificationRecord> snoozed = new ArrayList<>();
            snoozed.addAll(mSnoozedNotifications.values());
            return snoozed;
        }
    }

    /**
     * Snoozes a notification and schedules an alarm to repost at that time.
     */
    protected void snooze(NotificationRecord record, long duration) {
        String key = record.getKey();

        snooze(record);
        scheduleRepost(key, duration);
        Long activateAt = System.currentTimeMillis() + duration;
        synchronized (mLock) {
            mPersistedSnoozedNotifications.put(getTrimmedString(key), activateAt);
        }
    }

    /**
     * Records a snoozed notification.
     */
    protected void snooze(NotificationRecord record, String contextId) {
        if (contextId != null) {
            synchronized (mLock) {
                mPersistedSnoozedNotificationsWithContext.put(
                        getTrimmedString(record.getKey()),
                        getTrimmedString(contextId)
                );
            }
        }
        snooze(record);
    }

    private void snooze(NotificationRecord record) {
        if (DEBUG) {
            Slog.d(TAG, "Snoozing " + record.getKey());
        }
        synchronized (mLock) {
            mSnoozedNotifications.put(record.getKey(), record);
        }
    }

    private String getTrimmedString(String key) {
        if (key != null && key.length() > MAX_STRING_LENGTH) {
            return key.substring(0, MAX_STRING_LENGTH);
        }
        return key;
    }

    protected boolean cancel(int userId, String pkg, String tag, int id) {
        synchronized (mLock) {
            final Set<Map.Entry<String, NotificationRecord>> records =
                    mSnoozedNotifications.entrySet();
            for (Map.Entry<String, NotificationRecord> record : records) {
                final StatusBarNotification sbn = record.getValue().getSbn();
                if (sbn.getPackageName().equals(pkg) && sbn.getUserId() == userId
                        && Objects.equals(sbn.getTag(), tag) && sbn.getId() == id) {
                    record.getValue().isCanceled = true;
                    return true;
                }
            }
        }
        return false;
    }

    protected void cancel(int userId, boolean includeCurrentProfiles) {
        synchronized (mLock) {
            if (mSnoozedNotifications.size() == 0) {
                return;
            }
            IntArray userIds = new IntArray();
            userIds.add(userId);
            if (includeCurrentProfiles) {
                userIds = mUserProfiles.getCurrentProfileIds();
            }
            for (NotificationRecord r : mSnoozedNotifications.values()) {
                if (userIds.binarySearch(r.getUserId()) >= 0) {
                    r.isCanceled = true;
                }
            }
        }
    }

    protected boolean cancel(int userId, String pkg) {
        synchronized (mLock) {
            int n = mSnoozedNotifications.size();
            for (int i = 0; i < n; i++) {
                final NotificationRecord r = mSnoozedNotifications.valueAt(i);
                if (r.getSbn().getPackageName().equals(pkg) && r.getUserId() == userId) {
                    r.isCanceled = true;
                }
            }
            return true;
        }
    }

    /**
     * Updates the notification record so the most up to date information is shown on re-post.
     */
    protected void update(int userId, NotificationRecord record) {
        synchronized (mLock) {
            if (mSnoozedNotifications.containsKey(record.getKey())) {
                mSnoozedNotifications.put(record.getKey(), record);
            }
        }
    }

    /**
     * Unsnooze & repost all snoozed notifications for userId and its profiles
     */
    protected void repostAll(IntArray userIds) {
        synchronized (mLock) {
            List<NotificationRecord> snoozedNotifications = getSnoozed();
            for (NotificationRecord r : snoozedNotifications) {
                if (userIds.binarySearch(r.getUserId()) >= 0) {
                    repost(r.getKey(), r.getUserId(), false);
                }
            }
        }
    }

    protected void repost(String key, boolean muteOnReturn) {
        synchronized (mLock) {
            final NotificationRecord r = mSnoozedNotifications.get(key);
            if (r != null) {
                repost(key, r.getUserId(), muteOnReturn);
            }
        }
    }

    protected void repost(String key, int userId, boolean muteOnReturn) {
        final String trimmedKey = getTrimmedString(key);

        NotificationRecord record;
        synchronized (mLock) {
            mPersistedSnoozedNotifications.remove(trimmedKey);
            mPersistedSnoozedNotificationsWithContext.remove(trimmedKey);
            record = mSnoozedNotifications.remove(key);
        }

        if (record != null && !record.isCanceled) {
            final PendingIntent pi = createPendingIntent(record.getKey());
            mAm.cancel(pi);
            MetricsLogger.action(record.getLogMaker()
                    .setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED)
                    .setType(MetricsProto.MetricsEvent.TYPE_OPEN));
            mCallback.repost(record.getUserId(), record, muteOnReturn);
        }
    }

    protected void repostGroupSummary(String pkg, int userId, String groupKey) {
        synchronized (mLock) {
            String groupSummaryKey = null;
            int n = mSnoozedNotifications.size();
            for (int i = 0; i < n; i++) {
                final NotificationRecord potentialGroupSummary = mSnoozedNotifications.valueAt(i);
                if (potentialGroupSummary.getSbn().getPackageName().equals(pkg)
                        && potentialGroupSummary.getUserId() == userId
                        && potentialGroupSummary.getSbn().isGroup()
                        && potentialGroupSummary.getNotification().isGroupSummary()
                        && groupKey.equals(potentialGroupSummary.getGroupKey())) {
                    groupSummaryKey = potentialGroupSummary.getKey();
                    break;
                }
            }

            if (groupSummaryKey != null) {
                NotificationRecord record = mSnoozedNotifications.remove(groupSummaryKey);
                String trimmedKey = getTrimmedString(groupSummaryKey);
                mPersistedSnoozedNotificationsWithContext.remove(trimmedKey);
                mPersistedSnoozedNotifications.remove(trimmedKey);

                if (record != null && !record.isCanceled) {
                    Runnable runnable = () -> {
                        MetricsLogger.action(record.getLogMaker()
                                .setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED)
                                .setType(MetricsProto.MetricsEvent.TYPE_OPEN));
                        mCallback.repost(record.getUserId(), record, false);
                    };
                    runnable.run();
                }
            }
        }
    }

    protected void clearData(int userId, String pkg) {
        synchronized (mLock) {
            int n = mSnoozedNotifications.size();
            for (int i = n - 1; i >= 0; i--) {
                final NotificationRecord record = mSnoozedNotifications.valueAt(i);
                if (record.getUserId() == userId && record.getSbn().getPackageName().equals(pkg)) {
                    mSnoozedNotifications.removeAt(i);
                    String trimmedKey = getTrimmedString(record.getKey());
                    mPersistedSnoozedNotificationsWithContext.remove(trimmedKey);
                    mPersistedSnoozedNotifications.remove(trimmedKey);
                    Runnable runnable = () -> {
                        final PendingIntent pi = createPendingIntent(record.getKey());
                        mAm.cancel(pi);
                        MetricsLogger.action(record.getLogMaker()
                                .setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED)
                                .setType(MetricsProto.MetricsEvent.TYPE_DISMISS));
                    };
                    runnable.run();
                }
            }
        }
    }

    protected void clearData(int userId) {
        synchronized (mLock) {
            int n = mSnoozedNotifications.size();
            for (int i = n - 1; i >= 0; i--) {
                final NotificationRecord record = mSnoozedNotifications.valueAt(i);
                if (record.getUserId() == userId) {
                    mSnoozedNotifications.removeAt(i);
                    String trimmedKey = getTrimmedString(record.getKey());
                    mPersistedSnoozedNotificationsWithContext.remove(trimmedKey);
                    mPersistedSnoozedNotifications.remove(trimmedKey);

                    Runnable runnable = () -> {
                        final PendingIntent pi = createPendingIntent(record.getKey());
                        mAm.cancel(pi);
                        MetricsLogger.action(record.getLogMaker()
                                .setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED)
                                .setType(MetricsProto.MetricsEvent.TYPE_DISMISS));
                    };
                    runnable.run();
                }
            }
        }
    }

    private PendingIntent createPendingIntent(String key) {
        return PendingIntent.getBroadcast(mContext,
                REQUEST_CODE_REPOST,
                new Intent(REPOST_ACTION)
                        .setPackage(PackageManagerService.PLATFORM_PACKAGE_NAME)
                        .setData(new Uri.Builder().scheme(REPOST_SCHEME).appendPath(key).build())
                        .addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
                        .putExtra(EXTRA_KEY, key),
                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
    }

    public void scheduleRepostsForPersistedNotifications(long currentTime) {
        synchronized (mLock) {
            for (int i = 0; i < mPersistedSnoozedNotifications.size(); i++) {
                String key = mPersistedSnoozedNotifications.keyAt(i);
                Long time = mPersistedSnoozedNotifications.valueAt(i);
                if (time != null && time > currentTime) {
                    scheduleRepostAtTime(key, time);
                }
            }
        }
    }

    private void scheduleRepost(String key, long duration) {
        scheduleRepostAtTime(key, System.currentTimeMillis() + duration);
    }

    private void scheduleRepostAtTime(String key, long time) {
        Runnable runnable = () -> {
            final long identity = Binder.clearCallingIdentity();
            try {
                final PendingIntent pi = createPendingIntent(key);
                mAm.cancel(pi);
                if (DEBUG) Slog.d(TAG, "Scheduling evaluate for " + new Date(time));
                mAm.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pi);
            } finally {
                Binder.restoreCallingIdentity(identity);
            }
        };
        runnable.run();
    }

    public void dump(PrintWriter pw, NotificationManagerService.DumpFilter filter) {
        synchronized (mLock) {
            pw.println("\n  Snoozed notifications:");
            for (String key : mSnoozedNotifications.keySet()) {
                pw.print(INDENT);
                pw.println("key: " + key);
            }
            pw.println("\n Pending snoozed notifications");
            for (String key : mPersistedSnoozedNotifications.keySet()) {
                pw.print(INDENT);
                pw.println("key: " + key + " until: " + mPersistedSnoozedNotifications.get(key));
            }
        }
    }

    protected void writeXml(TypedXmlSerializer out) throws IOException {
        synchronized (mLock) {
            final long currentTime = System.currentTimeMillis();
            out.startTag(null, XML_TAG_NAME);
            writeXml(out, mPersistedSnoozedNotifications, XML_SNOOZED_NOTIFICATION,
                    value -> {
                        if (value < currentTime) {
                            return;
                        }
                        out.attributeLong(null, XML_SNOOZED_NOTIFICATION_TIME,
                                value);
                    });
            writeXml(out, mPersistedSnoozedNotificationsWithContext,
                    XML_SNOOZED_NOTIFICATION_CONTEXT,
                    value -> {
                        out.attribute(null, XML_SNOOZED_NOTIFICATION_CONTEXT_ID,
                                value);
                    });
            out.endTag(null, XML_TAG_NAME);
        }
    }

    private interface Inserter<T> {
        void insert(T t) throws IOException;
    }

    private <T> void writeXml(TypedXmlSerializer out, ArrayMap<String, T> targets, String tag,
            Inserter<T> attributeInserter) throws IOException {
        for (int j = 0; j < targets.size(); j++) {
            String key = targets.keyAt(j);
            // T is a String (snoozed until context) or Long (snoozed until time)
            T value = targets.valueAt(j);

            out.startTag(null, tag);

            attributeInserter.insert(value);

            out.attributeInt(null, XML_SNOOZED_NOTIFICATION_VERSION_LABEL,
                    XML_SNOOZED_NOTIFICATION_VERSION);
            out.attribute(null, XML_SNOOZED_NOTIFICATION_KEY, key);

            out.endTag(null, tag);
        }
    }

    protected void readXml(TypedXmlPullParser parser, long currentTime)
            throws XmlPullParserException, IOException {
        int type;
        while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) {
            String tag = parser.getName();
            if (type == XmlPullParser.END_TAG
                    && XML_TAG_NAME.equals(tag)) {
                break;
            }
            if (type == XmlPullParser.START_TAG
                    && (XML_SNOOZED_NOTIFICATION.equals(tag)
                        || tag.equals(XML_SNOOZED_NOTIFICATION_CONTEXT))
                    && parser.getAttributeInt(null, XML_SNOOZED_NOTIFICATION_VERSION_LABEL, -1)
                        == XML_SNOOZED_NOTIFICATION_VERSION) {
                try {
                    final String key = parser.getAttributeValue(null, XML_SNOOZED_NOTIFICATION_KEY);
                    if (tag.equals(XML_SNOOZED_NOTIFICATION)) {
                        final Long time = parser.getAttributeLong(
                                null, XML_SNOOZED_NOTIFICATION_TIME, 0);
                        if (time > currentTime) { //only read new stuff
                            synchronized (mLock) {
                                mPersistedSnoozedNotifications.put(key, time);
                            }
                        }
                    }
                    if (tag.equals(XML_SNOOZED_NOTIFICATION_CONTEXT)) {
                        final String creationId = parser.getAttributeValue(
                                null, XML_SNOOZED_NOTIFICATION_CONTEXT_ID);
                        synchronized (mLock) {
                            mPersistedSnoozedNotificationsWithContext.put(key, creationId);
                        }
                    }
                } catch (Exception e) {
                    Slog.e(TAG,  "Exception in reading snooze data from policy xml", e);
                }
            }
        }
    }

    @VisibleForTesting
    void setAlarmManager(AlarmManager am) {
        mAm = am;
    }

    protected interface Callback {
        void repost(int userId, NotificationRecord r, boolean muteOnReturn);
    }

    private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            if (DEBUG) {
                Slog.d(TAG, "Reposting notification");
            }
            if (REPOST_ACTION.equals(intent.getAction())) {
                repost(intent.getStringExtra(EXTRA_KEY), intent.getIntExtra(EXTRA_USER_ID,
                        UserHandle.USER_SYSTEM), false);
            }
        }
    };
}
