/*
 * Copyright (C) 2012 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.downloads;

import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE;
import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED;
import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION;
import static android.provider.Downloads.Impl.STATUS_QUEUED_FOR_WIFI;
import static android.provider.Downloads.Impl.STATUS_RUNNING;

import static com.android.providers.downloads.Constants.TAG;

import android.app.DownloadManager;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.ContentUris;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.database.Cursor;
import android.net.Uri;
import android.os.SystemClock;
import android.provider.Downloads;
import android.service.notification.StatusBarNotification;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.ArrayMap;
import android.util.IntArray;
import android.util.Log;
import android.util.LongSparseLongArray;

import com.android.internal.util.ArrayUtils;

import java.text.NumberFormat;

import javax.annotation.concurrent.GuardedBy;

/**
 * Update {@link NotificationManager} to reflect current download states.
 * Collapses similar downloads into a single notification, and builds
 * {@link PendingIntent} that launch towards {@link DownloadReceiver}.
 */
public class DownloadNotifier {

    private static final int TYPE_ACTIVE = 1;
    private static final int TYPE_WAITING = 2;
    private static final int TYPE_COMPLETE = 3;

    private static final String CHANNEL_ACTIVE = "active";
    private static final String CHANNEL_WAITING = "waiting";
    private static final String CHANNEL_COMPLETE = "complete";

    private final Context mContext;
    private final NotificationManager mNotifManager;

    /**
     * Currently active notifications, mapped from clustering tag to timestamp
     * when first shown.
     *
     * @see #buildNotificationTag(Cursor)
     */
    @GuardedBy("mActiveNotifs")
    private final ArrayMap<String, Long> mActiveNotifs = new ArrayMap<>();

    /**
     * Current speed of active downloads, mapped from download ID to speed in
     * bytes per second.
     */
    @GuardedBy("mDownloadSpeed")
    private final LongSparseLongArray mDownloadSpeed = new LongSparseLongArray();

    /**
     * Last time speed was reproted, mapped from download ID to
     * {@link SystemClock#elapsedRealtime()}.
     */
    @GuardedBy("mDownloadSpeed")
    private final LongSparseLongArray mDownloadTouch = new LongSparseLongArray();

    public DownloadNotifier(Context context) {
        mContext = context;
        mNotifManager = context.getSystemService(NotificationManager.class);

        // Ensure that all our channels are ready to use
        mNotifManager.createNotificationChannel(new NotificationChannel(CHANNEL_ACTIVE,
                context.getText(R.string.download_running),
                NotificationManager.IMPORTANCE_MIN));
        mNotifManager.createNotificationChannel(new NotificationChannel(CHANNEL_WAITING,
                context.getText(R.string.download_queued),
                NotificationManager.IMPORTANCE_DEFAULT));
        mNotifManager.createNotificationChannel(new NotificationChannel(CHANNEL_COMPLETE,
                context.getText(com.android.internal.R.string.done_label),
                NotificationManager.IMPORTANCE_DEFAULT));
    }

    public void init() {
        synchronized (mActiveNotifs) {
            mActiveNotifs.clear();
            final StatusBarNotification[] notifs = mNotifManager.getActiveNotifications();
            if (!ArrayUtils.isEmpty(notifs)) {
                for (StatusBarNotification notif : notifs) {
                    mActiveNotifs.put(notif.getTag(), notif.getPostTime());
                }
            }
        }
    }

    /**
     * Notify the current speed of an active download, used for calculating
     * estimated remaining time.
     */
    public void notifyDownloadSpeed(long id, long bytesPerSecond) {
        synchronized (mDownloadSpeed) {
            if (bytesPerSecond != 0) {
                mDownloadSpeed.put(id, bytesPerSecond);
                mDownloadTouch.put(id, SystemClock.elapsedRealtime());
            } else {
                mDownloadSpeed.delete(id);
                mDownloadTouch.delete(id);
            }
        }
    }

    private interface UpdateQuery {
        final String[] PROJECTION = new String[] {
                Downloads.Impl._ID,
                Downloads.Impl.COLUMN_STATUS,
                Downloads.Impl.COLUMN_VISIBILITY,
                Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE,
                Downloads.Impl.COLUMN_CURRENT_BYTES,
                Downloads.Impl.COLUMN_TOTAL_BYTES,
                Downloads.Impl.COLUMN_DESTINATION,
                Downloads.Impl.COLUMN_TITLE,
                Downloads.Impl.COLUMN_DESCRIPTION,
        };

        final int _ID = 0;
        final int STATUS = 1;
        final int VISIBILITY = 2;
        final int NOTIFICATION_PACKAGE = 3;
        final int CURRENT_BYTES = 4;
        final int TOTAL_BYTES = 5;
        final int DESTINATION = 6;
        final int TITLE = 7;
        final int DESCRIPTION = 8;
    }

    public void update() {
        try (Cursor cursor = mContext.getContentResolver().query(
                Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, UpdateQuery.PROJECTION,
                Downloads.Impl.COLUMN_DELETED + " == '0'", null, null)) {
            synchronized (mActiveNotifs) {
                updateWithLocked(cursor);
            }
        }
    }

    private void updateWithLocked(Cursor cursor) {
        final Resources res = mContext.getResources();

        // Cluster downloads together
        final ArrayMap<String, IntArray> clustered = new ArrayMap<>();
        while (cursor.moveToNext()) {
            final String tag = buildNotificationTag(cursor);
            if (tag != null) {
                IntArray cluster = clustered.get(tag);
                if (cluster == null) {
                    cluster = new IntArray();
                    clustered.put(tag, cluster);
                }
                cluster.add(cursor.getPosition());
            }
        }

        // Build notification for each cluster
        for (int i = 0; i < clustered.size(); i++) {
            final String tag = clustered.keyAt(i);
            final IntArray cluster = clustered.valueAt(i);
            final int type = getNotificationTagType(tag);

            final Notification.Builder builder;
            if (type == TYPE_ACTIVE) {
                builder = new Notification.Builder(mContext, CHANNEL_ACTIVE);
                builder.setSmallIcon(android.R.drawable.stat_sys_download);
            } else if (type == TYPE_WAITING) {
                builder = new Notification.Builder(mContext, CHANNEL_WAITING);
                builder.setSmallIcon(android.R.drawable.stat_sys_warning);
            } else if (type == TYPE_COMPLETE) {
                builder = new Notification.Builder(mContext, CHANNEL_COMPLETE);
                builder.setSmallIcon(android.R.drawable.stat_sys_download_done);
            } else {
                continue;
            }

            builder.setColor(res.getColor(
                    com.android.internal.R.color.system_notification_accent_color));

            // Use time when cluster was first shown to avoid shuffling
            final long firstShown;
            if (mActiveNotifs.containsKey(tag)) {
                firstShown = mActiveNotifs.get(tag);
            } else {
                firstShown = System.currentTimeMillis();
                mActiveNotifs.put(tag, firstShown);
            }
            builder.setWhen(firstShown);
            builder.setOnlyAlertOnce(true);

            // Build action intents
            if (type == TYPE_ACTIVE || type == TYPE_WAITING) {
                final long[] downloadIds = getDownloadIds(cursor, cluster);

                // build a synthetic uri for intent identification purposes
                final Uri uri = new Uri.Builder().scheme("active-dl").appendPath(tag).build();
                final Intent intent = new Intent(Constants.ACTION_LIST,
                        uri, mContext, DownloadReceiver.class);
                intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
                intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS,
                        downloadIds);
                builder.setContentIntent(PendingIntent.getBroadcast(mContext,
                        0, intent,
                        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
                if (type == TYPE_ACTIVE) {
                    builder.setOngoing(true);
                }

                // Add a Cancel action
                final Uri cancelUri = new Uri.Builder().scheme("cancel-dl").appendPath(tag).build();
                final Intent cancelIntent = new Intent(Constants.ACTION_CANCEL,
                        cancelUri, mContext, DownloadReceiver.class);
                cancelIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
                cancelIntent.putExtra(DownloadReceiver.EXTRA_CANCELED_DOWNLOAD_IDS, downloadIds);
                cancelIntent.putExtra(DownloadReceiver.EXTRA_CANCELED_DOWNLOAD_NOTIFICATION_TAG, tag);

                builder.addAction(
                    android.R.drawable.ic_menu_close_clear_cancel,
                    res.getString(R.string.button_cancel_download),
                    PendingIntent.getBroadcast(mContext,
                            0, cancelIntent,
                            PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));

            } else if (type == TYPE_COMPLETE) {
                cursor.moveToPosition(cluster.get(0));
                final long id = cursor.getLong(UpdateQuery._ID);
                final int status = cursor.getInt(UpdateQuery.STATUS);
                final int destination = cursor.getInt(UpdateQuery.DESTINATION);

                final Uri uri = ContentUris.withAppendedId(
                        Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, id);
                builder.setAutoCancel(true);

                final String action;
                if (Downloads.Impl.isStatusError(status)) {
                    action = Constants.ACTION_LIST;
                } else {
                    action = Constants.ACTION_OPEN;
                }

                final Intent intent = new Intent(action, uri, mContext, DownloadReceiver.class);
                intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
                intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS,
                        getDownloadIds(cursor, cluster));
                builder.setContentIntent(PendingIntent.getBroadcast(mContext,
                        0, intent,
                        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));

                final Intent hideIntent = new Intent(Constants.ACTION_HIDE,
                        uri, mContext, DownloadReceiver.class);
                hideIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
                builder.setDeleteIntent(PendingIntent.getBroadcast(mContext, 0, hideIntent,
                            PendingIntent.FLAG_IMMUTABLE));
            }

            // Calculate and show progress
            String remainingLongText = null;
            String remainingShortText = null;
            String percentText = null;
            if (type == TYPE_ACTIVE) {
                long current = 0;
                long total = 0;
                long speed = 0;
                synchronized (mDownloadSpeed) {
                    for (int j = 0; j < cluster.size(); j++) {
                        cursor.moveToPosition(cluster.get(j));

                        final long id = cursor.getLong(UpdateQuery._ID);
                        final long currentBytes = cursor.getLong(UpdateQuery.CURRENT_BYTES);
                        final long totalBytes = cursor.getLong(UpdateQuery.TOTAL_BYTES);

                        if (totalBytes != -1) {
                            current += currentBytes;
                            total += totalBytes;
                            speed += mDownloadSpeed.get(id);
                        }
                    }
                }

                if (total > 0) {
                    percentText =
                            NumberFormat.getPercentInstance().format((double) current / total);

                    if (speed > 0) {
                        final long remainingMillis = ((total - current) * 1000) / speed;
                        remainingLongText = getRemainingText(res, remainingMillis,
                            DateUtils.LENGTH_LONG);
                        remainingShortText = getRemainingText(res, remainingMillis,
                            DateUtils.LENGTH_SHORTEST);
                    }

                    final int percent = (int) ((current * 100) / total);
                    builder.setProgress(100, percent, false);
                } else {
                    builder.setProgress(100, 0, true);
                }
            }

            // Build titles and description
            final Notification notif;
            if (cluster.size() == 1) {
                cursor.moveToPosition(cluster.get(0));
                builder.setContentTitle(getDownloadTitle(res, cursor));

                if (type == TYPE_ACTIVE) {
                    final String description = cursor.getString(UpdateQuery.DESCRIPTION);
                    if (!TextUtils.isEmpty(description)) {
                        builder.setContentText(description);
                    } else {
                        builder.setContentText(remainingLongText);
                    }
                    builder.setContentInfo(percentText);

                } else if (type == TYPE_WAITING) {
                    builder.setContentText(
                            res.getString(R.string.notification_need_wifi_for_size));

                } else if (type == TYPE_COMPLETE) {
                    final int status = cursor.getInt(UpdateQuery.STATUS);
                    if (Downloads.Impl.isStatusError(status)) {
                        builder.setContentText(res.getText(R.string.notification_download_failed));
                    } else if (Downloads.Impl.isStatusSuccess(status)) {
                        builder.setContentText(
                                res.getText(R.string.notification_download_complete));
                    }
                }

                notif = builder.build();

            } else {
                final Notification.InboxStyle inboxStyle = new Notification.InboxStyle(builder);

                for (int j = 0; j < cluster.size(); j++) {
                    cursor.moveToPosition(cluster.get(j));
                    inboxStyle.addLine(getDownloadTitle(res, cursor));
                }

                if (type == TYPE_ACTIVE) {
                    builder.setContentTitle(res.getQuantityString(
                            R.plurals.notif_summary_active, cluster.size(), cluster.size()));
                    builder.setContentText(remainingLongText);
                    builder.setContentInfo(percentText);
                    inboxStyle.setSummaryText(remainingShortText);

                } else if (type == TYPE_WAITING) {
                    builder.setContentTitle(res.getQuantityString(
                            R.plurals.notif_summary_waiting, cluster.size(), cluster.size()));
                    builder.setContentText(
                            res.getString(R.string.notification_need_wifi_for_size));
                    inboxStyle.setSummaryText(
                            res.getString(R.string.notification_need_wifi_for_size));
                }

                notif = inboxStyle.build();
            }

            mNotifManager.notify(tag, 0, notif);
        }

        // Remove stale tags that weren't renewed
        for (int i = 0; i < mActiveNotifs.size();) {
            final String tag = mActiveNotifs.keyAt(i);
            if (clustered.containsKey(tag)) {
                i++;
            } else {
                mNotifManager.cancel(tag, 0);
                mActiveNotifs.removeAt(i);
            }
        }
    }

    private String getRemainingText(Resources res, long remainingMillis, int abbrev) {
        return res.getString(R.string.download_remaining,
            DateUtils.formatDuration(remainingMillis, abbrev));
    }

    private static CharSequence getDownloadTitle(Resources res, Cursor cursor) {
        final String title = cursor.getString(UpdateQuery.TITLE);
        if (!TextUtils.isEmpty(title)) {
            return title;
        } else {
            return res.getString(R.string.download_unknown_title);
        }
    }

    private long[] getDownloadIds(Cursor cursor, IntArray cluster) {
        final long[] ids = new long[cluster.size()];
        for (int i = 0; i < cluster.size(); i++) {
            cursor.moveToPosition(cluster.get(i));
            ids[i] = cursor.getLong(UpdateQuery._ID);
        }
        return ids;
    }

    public void dumpSpeeds() {
        synchronized (mDownloadSpeed) {
            for (int i = 0; i < mDownloadSpeed.size(); i++) {
                final long id = mDownloadSpeed.keyAt(i);
                final long delta = SystemClock.elapsedRealtime() - mDownloadTouch.get(id);
                Log.d(TAG, "Download " + id + " speed " + mDownloadSpeed.valueAt(i) + "bps, "
                        + delta + "ms ago");
            }
        }
    }

    /**
     * Build tag used for collapsing several downloads into a single
     * {@link Notification}.
     */
    private static String buildNotificationTag(Cursor cursor) {
        final long id = cursor.getLong(UpdateQuery._ID);
        final int status = cursor.getInt(UpdateQuery.STATUS);
        final int visibility = cursor.getInt(UpdateQuery.VISIBILITY);
        final String notifPackage = cursor.getString(UpdateQuery.NOTIFICATION_PACKAGE);

        if (isQueuedAndVisible(status, visibility)) {
            return TYPE_WAITING + ":" + notifPackage;
        } else if (isActiveAndVisible(status, visibility)) {
            return TYPE_ACTIVE + ":" + notifPackage;
        } else if (isCompleteAndVisible(status, visibility)) {
            // Complete downloads always have unique notifs
            return TYPE_COMPLETE + ":" + id;
        } else {
            return null;
        }
    }

    /**
     * Return the cluster type of the given tag, as created by
     * {@link #buildNotificationTag(Cursor)}.
     */
    private static int getNotificationTagType(String tag) {
        return Integer.parseInt(tag.substring(0, tag.indexOf(':')));
    }

    private static boolean isQueuedAndVisible(int status, int visibility) {
        return status == STATUS_QUEUED_FOR_WIFI &&
                (visibility == VISIBILITY_VISIBLE
                || visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
    }

    private static boolean isActiveAndVisible(int status, int visibility) {
        return status == STATUS_RUNNING &&
                (visibility == VISIBILITY_VISIBLE
                || visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
    }

    private static boolean isCompleteAndVisible(int status, int visibility) {
        return Downloads.Impl.isStatusCompleted(status) &&
                (visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED
                || visibility == VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION);
    }
}
