/*
 * Copyright (C) 2019 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.app.NotificationHistory;
import android.app.NotificationHistory.HistoricalNotification;
import android.os.Handler;
import android.util.AtomicFile;
import android.util.Slog;

import com.android.internal.annotations.VisibleForTesting;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.util.Arrays;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.Set;

/**
 * Provides an interface to write and query for notification history data for a user from a Protocol
 * Buffer database.
 *
 * Periodically writes the buffered history to disk but can also accept force writes based on
 * outside changes (like a pending shutdown).
 */
public class NotificationHistoryDatabase {
    private static final int DEFAULT_CURRENT_VERSION = 1;

    private static final String TAG = "NotiHistoryDatabase";
    private static final boolean DEBUG = NotificationManagerService.DBG;
    private static final int HISTORY_RETENTION_DAYS = 1;
    private static final long WRITE_BUFFER_INTERVAL_MS = 1000 * 60 * 20;
    private static final long INVALID_FILE_TIME_MS = -1;

    private final Object mLock = new Object();
    private final Handler mFileWriteHandler;
    @VisibleForTesting
    // List of files holding history information, sorted newest to oldest
    final LinkedList<AtomicFile> mHistoryFiles;
    private final File mHistoryDir;
    private final File mVersionFile;
    // Current version of the database files schema
    private int mCurrentVersion;
    private final WriteBufferRunnable mWriteBufferRunnable;

    // Object containing posted notifications that have not yet been written to disk
    @VisibleForTesting
    NotificationHistory mBuffer;

    public NotificationHistoryDatabase(Handler fileWriteHandler, File dir) {
        mCurrentVersion = DEFAULT_CURRENT_VERSION;
        mFileWriteHandler = fileWriteHandler;
        mVersionFile = new File(dir, "version");
        mHistoryDir = new File(dir, "history");
        mHistoryFiles = new LinkedList<>();
        mBuffer = new NotificationHistory();
        mWriteBufferRunnable = new WriteBufferRunnable();
    }

    public void init() {
        synchronized (mLock) {
            try {
                if (!mHistoryDir.exists() && !mHistoryDir.mkdir()) {
                    throw new IllegalStateException("could not create history directory");
                }
                mVersionFile.createNewFile();
            } catch (Exception e) {
                Slog.e(TAG, "could not create needed files", e);
            }

            checkVersionAndBuildLocked();
            indexFilesLocked();
            prune();
        }
    }

    private void indexFilesLocked() {
        mHistoryFiles.clear();
        final File[] files = mHistoryDir.listFiles();
        if (files == null) {
            return;
        }

        // Sort with newest files first
        Arrays.sort(files, (lhs, rhs) -> Long.compare(safeParseLong(rhs.getName()),
                safeParseLong(lhs.getName())));

        for (File file : files) {
            mHistoryFiles.addLast(new AtomicFile(file));
        }
    }

    private void checkVersionAndBuildLocked() {
        int version;
        try (BufferedReader reader = new BufferedReader(new FileReader(mVersionFile))) {
            version = Integer.parseInt(reader.readLine());
        } catch (NumberFormatException | IOException e) {
            version = 0;
        }

        if (version != mCurrentVersion && mVersionFile.exists()) {
            try (BufferedWriter writer = new BufferedWriter(new FileWriter(mVersionFile))) {
                writer.write(Integer.toString(mCurrentVersion));
                writer.write("\n");
                writer.flush();
            } catch (IOException e) {
                Slog.e(TAG, "Failed to write new version");
                throw new RuntimeException(e);
            }
        }
    }

    public void forceWriteToDisk() {
        mFileWriteHandler.post(mWriteBufferRunnable);
    }

    public void onPackageRemoved(String packageName) {
        RemovePackageRunnable rpr = new RemovePackageRunnable(packageName);
        mFileWriteHandler.post(rpr);
    }

    public void deleteNotificationHistoryItem(String pkg, long postedTime) {
        RemoveNotificationRunnable rnr = new RemoveNotificationRunnable(pkg, postedTime);
        mFileWriteHandler.post(rnr);
    }

    public void deleteConversations(String pkg, Set<String> conversationIds) {
        RemoveConversationRunnable rcr = new RemoveConversationRunnable(pkg, conversationIds);
        mFileWriteHandler.post(rcr);
    }

    public void deleteNotificationChannel(String pkg, String channelId) {
        RemoveChannelRunnable rcr = new RemoveChannelRunnable(pkg, channelId);
        mFileWriteHandler.post(rcr);
    }

    public void addNotification(final HistoricalNotification notification) {
        synchronized (mLock) {
            mBuffer.addNewNotificationToWrite(notification);
            // Each time we have new history to write to disk, schedule a write in [interval] ms
            if (mBuffer.getHistoryCount() == 1) {
                mFileWriteHandler.postDelayed(mWriteBufferRunnable, WRITE_BUFFER_INTERVAL_MS);
            }
        }
    }

    public NotificationHistory readNotificationHistory() {
        synchronized (mLock) {
            NotificationHistory notifications = new NotificationHistory();
            notifications.addNotificationsToWrite(mBuffer);

            for (AtomicFile file : mHistoryFiles) {
                try {
                    readLocked(
                            file, notifications, new NotificationHistoryFilter.Builder().build());
                } catch (Exception e) {
                    Slog.e(TAG, "error reading " + file.getBaseFile().getAbsolutePath(), e);
                }
            }

            return notifications;
        }
    }

    public NotificationHistory readNotificationHistory(String packageName, String channelId,
            int maxNotifications) {
        synchronized (mLock) {
            NotificationHistory notifications = new NotificationHistory();

            for (AtomicFile file : mHistoryFiles) {
                try {
                    readLocked(file, notifications,
                            new NotificationHistoryFilter.Builder()
                                    .setPackage(packageName)
                                    .setChannel(packageName, channelId)
                                    .setMaxNotifications(maxNotifications)
                                    .build());
                    if (maxNotifications == notifications.getHistoryCount()) {
                        // No need to read any more files
                        break;
                    }
                } catch (Exception e) {
                    Slog.e(TAG, "error reading " + file.getBaseFile().getAbsolutePath(), e);
                }
            }

            return notifications;
        }
    }

    public void disableHistory() {
        synchronized (mLock) {
            for (AtomicFile file : mHistoryFiles) {
                file.delete();
            }
            mHistoryDir.delete();
            mHistoryFiles.clear();
        }
    }

    /**
     * Remove any files that are too old.
     */
    void prune() {
        prune(HISTORY_RETENTION_DAYS, System.currentTimeMillis());
    }

    /**
     * Remove any files that are too old.
     */
    void prune(final int retentionDays, final long currentTimeMillis) {
        synchronized (mLock) {
            GregorianCalendar retentionBoundary = new GregorianCalendar();
            retentionBoundary.setTimeInMillis(currentTimeMillis);
            retentionBoundary.add(Calendar.DATE, -1 * retentionDays);

            for (int i = mHistoryFiles.size() - 1; i >= 0; i--) {
                final AtomicFile currentOldestFile = mHistoryFiles.get(i);
                final long creationTime = safeParseLong(
                        currentOldestFile.getBaseFile().getName());
                if (DEBUG) {
                    Slog.d(TAG, "File " + currentOldestFile.getBaseFile().getName()
                            + " created on " + creationTime);
                }

                if (creationTime <= retentionBoundary.getTimeInMillis()) {
                    deleteFile(currentOldestFile);
                }
            }
        }
    }

    /**
     * Remove the first entry from the list of history files whose file matches the given file path.
     *
     * This method is necessary for anything that only has an absolute file path rather than an
     * AtomicFile object from the list of history files.
     *
     * filePath should be an absolute path.
     */
    void removeFilePathFromHistory(String filePath) {
        if (filePath == null) {
            return;
        }

        Iterator<AtomicFile> historyFileItr = mHistoryFiles.iterator();
        while (historyFileItr.hasNext()) {
            final AtomicFile af = historyFileItr.next();
            if (af != null && filePath.equals(af.getBaseFile().getAbsolutePath())) {
                historyFileItr.remove();
                return;
            }
        }
    }

    private void deleteFile(AtomicFile file) {
        if (DEBUG) {
            Slog.d(TAG, "Removed " + file.getBaseFile().getName());
        }
        file.delete();
        // TODO: delete all relevant bitmaps, once they exist
        removeFilePathFromHistory(file.getBaseFile().getAbsolutePath());
    }

    private void writeLocked(AtomicFile file, NotificationHistory notifications)
            throws IOException {
        FileOutputStream fos = file.startWrite();
        try {
            NotificationHistoryProtoHelper.write(fos, notifications, mCurrentVersion);
            file.finishWrite(fos);
            fos = null;
        } finally {
            // When fos is null (successful write), this will no-op
            file.failWrite(fos);
        }
    }

    private static void readLocked(AtomicFile file, NotificationHistory notificationsOut,
            NotificationHistoryFilter filter) throws IOException {
        FileInputStream in = null;
        try {
            in = file.openRead();
            NotificationHistoryProtoHelper.read(in, notificationsOut, filter);
        } catch (FileNotFoundException e) {
            Slog.e(TAG, "Cannot open " + file.getBaseFile().getAbsolutePath(), e);
            throw e;
        } finally {
            if (in != null) {
                in.close();
            }
        }
    }

    private static long safeParseLong(String fileName) {
        // AtomicFile will create copies of the numeric files with ".new" and ".bak"
        // over the course of its processing. If these files still exist on boot we need to clean
        // them up
        try {
            return Long.parseLong(fileName);
        } catch (NumberFormatException e) {
            return INVALID_FILE_TIME_MS;
        }
    }

    final class WriteBufferRunnable implements Runnable {

        @Override
        public void run() {
            long time = System.currentTimeMillis();
            run(new AtomicFile(new File(mHistoryDir, String.valueOf(time))));
        }

        void run(AtomicFile file) {
            synchronized (mLock) {
                if (DEBUG) Slog.d(TAG, "WriteBufferRunnable "
                        + file.getBaseFile().getAbsolutePath());
                try {
                    writeLocked(file, mBuffer);
                    mHistoryFiles.addFirst(file);
                    mBuffer = new NotificationHistory();
                } catch (IOException e) {
                    Slog.e(TAG, "Failed to write buffer to disk. not flushing buffer", e);
                }
            }
        }
    }

    private final class RemovePackageRunnable implements Runnable {
        private String mPkg;

        public RemovePackageRunnable(String pkg) {
            mPkg = pkg;
        }

        @Override
        public void run() {
            if (DEBUG) Slog.d(TAG, "RemovePackageRunnable " + mPkg);
            synchronized (mLock) {
                // Remove packageName entries from pending history
                mBuffer.removeNotificationsFromWrite(mPkg);

                Iterator<AtomicFile> historyFileItr = mHistoryFiles.iterator();
                while (historyFileItr.hasNext()) {
                    final AtomicFile af = historyFileItr.next();
                    try {
                        final NotificationHistory notifications = new NotificationHistory();
                        readLocked(af, notifications,
                                new NotificationHistoryFilter.Builder().build());
                        notifications.removeNotificationsFromWrite(mPkg);
                        writeLocked(af, notifications);
                    } catch (Exception e) {
                        Slog.e(TAG, "Cannot clean up file on pkg removal "
                                + af.getBaseFile().getAbsolutePath(), e);
                    }
                }
            }
        }
    }

    final class RemoveNotificationRunnable implements Runnable {
        private String mPkg;
        private long mPostedTime;
        private NotificationHistory mNotificationHistory;

        public RemoveNotificationRunnable(String pkg, long postedTime) {
            mPkg = pkg;
            mPostedTime = postedTime;
        }

        @VisibleForTesting
        void setNotificationHistory(NotificationHistory nh) {
            mNotificationHistory = nh;
        }

        @Override
        public void run() {
            if (DEBUG) Slog.d(TAG, "RemoveNotificationRunnable");
            synchronized (mLock) {
                // Remove from pending history
                mBuffer.removeNotificationFromWrite(mPkg, mPostedTime);

                Iterator<AtomicFile> historyFileItr = mHistoryFiles.iterator();
                while (historyFileItr.hasNext()) {
                    final AtomicFile af = historyFileItr.next();
                    try {
                        NotificationHistory notificationHistory = mNotificationHistory != null
                                ? mNotificationHistory
                                : new NotificationHistory();
                        readLocked(af, notificationHistory,
                                new NotificationHistoryFilter.Builder().build());
                        if(notificationHistory.removeNotificationFromWrite(mPkg, mPostedTime)) {
                            writeLocked(af, notificationHistory);
                        }
                    } catch (Exception e) {
                        Slog.e(TAG, "Cannot clean up file on notification removal "
                                + af.getBaseFile().getName(), e);
                    }
                }
            }
        }
    }

    final class RemoveConversationRunnable implements Runnable {
        private String mPkg;
        private Set<String> mConversationIds;
        private NotificationHistory mNotificationHistory;

        public RemoveConversationRunnable(String pkg, Set<String> conversationIds) {
            mPkg = pkg;
            mConversationIds = conversationIds;
        }

        @VisibleForTesting
        void setNotificationHistory(NotificationHistory nh) {
            mNotificationHistory = nh;
        }

        @Override
        public void run() {
            if (DEBUG) Slog.d(TAG, "RemoveConversationRunnable " + mPkg + " "  + mConversationIds);
            synchronized (mLock) {
                // Remove from pending history
                mBuffer.removeConversationsFromWrite(mPkg, mConversationIds);

                Iterator<AtomicFile> historyFileItr = mHistoryFiles.iterator();
                while (historyFileItr.hasNext()) {
                    final AtomicFile af = historyFileItr.next();
                    try {
                        NotificationHistory notificationHistory = mNotificationHistory != null
                                ? mNotificationHistory
                                : new NotificationHistory();
                        readLocked(af, notificationHistory,
                                new NotificationHistoryFilter.Builder().build());
                        if (notificationHistory.removeConversationsFromWrite(
                                mPkg, mConversationIds)) {
                            writeLocked(af, notificationHistory);
                        }
                    } catch (Exception e) {
                        Slog.e(TAG, "Cannot clean up file on conversation removal "
                                + af.getBaseFile().getName(), e);
                    }
                }
            }
        }
    }

    final class RemoveChannelRunnable implements Runnable {
        private String mPkg;
        private String mChannelId;
        private NotificationHistory mNotificationHistory;

        RemoveChannelRunnable(String pkg, String channelId) {
            mPkg = pkg;
            mChannelId = channelId;
        }

        @VisibleForTesting
        void setNotificationHistory(NotificationHistory nh) {
            mNotificationHistory = nh;
        }

        @Override
        public void run() {
            if (DEBUG) Slog.d(TAG, "RemoveChannelRunnable");
            synchronized (mLock) {
                // Remove from pending history
                mBuffer.removeChannelFromWrite(mPkg, mChannelId);

                Iterator<AtomicFile> historyFileItr = mHistoryFiles.iterator();
                while (historyFileItr.hasNext()) {
                    final AtomicFile af = historyFileItr.next();
                    try {
                        NotificationHistory notificationHistory = mNotificationHistory != null
                                ? mNotificationHistory
                                : new NotificationHistory();
                        readLocked(af, notificationHistory,
                                new NotificationHistoryFilter.Builder().build());
                        if (notificationHistory.removeChannelFromWrite(mPkg, mChannelId)) {
                            writeLocked(af, notificationHistory);
                        }
                    } catch (Exception e) {
                        Slog.e(TAG, "Cannot clean up file on channel removal "
                                + af.getBaseFile().getName(), e);
                    }
                }
            }
        }
    }
}
