/*
 * Copyright (C) 2020 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.people.data;

import android.annotation.MainThread;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.WorkerThread;
import android.content.LocusId;
import android.net.Uri;
import android.util.ArrayMap;
import android.util.Slog;
import android.util.proto.ProtoInputStream;

import com.android.internal.annotations.GuardedBy;
import com.android.server.people.ConversationInfosProto;

import com.google.android.collect.Lists;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.Consumer;

/**
 * The store that stores and accesses the conversations data for a package.
 */
class ConversationStore {

    private static final String TAG = ConversationStore.class.getSimpleName();

    private static final String CONVERSATIONS_FILE_NAME = "conversations";

    private static final int CONVERSATION_INFOS_END_TOKEN = -1;

    // Shortcut ID -> Conversation Info
    @GuardedBy("this")
    private final Map<String, ConversationInfo> mConversationInfoMap = new ArrayMap<>();

    // Locus ID -> Shortcut ID
    @GuardedBy("this")
    private final Map<LocusId, String> mLocusIdToShortcutIdMap = new ArrayMap<>();

    // Contact URI -> Shortcut ID
    @GuardedBy("this")
    private final Map<Uri, String> mContactUriToShortcutIdMap = new ArrayMap<>();

    // Phone Number -> Shortcut ID
    @GuardedBy("this")
    private final Map<String, String> mPhoneNumberToShortcutIdMap = new ArrayMap<>();

    // Notification Channel ID -> Shortcut ID
    @GuardedBy("this")
    private final Map<String, String> mNotifChannelIdToShortcutIdMap = new ArrayMap<>();

    private final ScheduledExecutorService mScheduledExecutorService;
    private final File mPackageDir;

    private ConversationInfosProtoDiskReadWriter mConversationInfosProtoDiskReadWriter;

    ConversationStore(@NonNull File packageDir,
            @NonNull ScheduledExecutorService scheduledExecutorService) {
        mScheduledExecutorService = scheduledExecutorService;
        mPackageDir = packageDir;
    }

    /**
     * Loads conversations from disk to memory in a background thread. This should be called
     * after the device powers on and the user has been unlocked.
     */
    @WorkerThread
    synchronized void loadConversationsFromDisk() {
        ConversationInfosProtoDiskReadWriter conversationInfosProtoDiskReadWriter =
                getConversationInfosProtoDiskReadWriter();
        if (conversationInfosProtoDiskReadWriter == null) {
            return;
        }
        List<ConversationInfo> conversationsOnDisk =
                conversationInfosProtoDiskReadWriter.read(CONVERSATIONS_FILE_NAME);
        if (conversationsOnDisk == null) {
            return;
        }
        for (ConversationInfo conversationInfo : conversationsOnDisk) {
            updateConversationsInMemory(conversationInfo);
        }
    }

    /**
     * Immediately flushes current conversations to disk. This should be called when device is
     * powering off.
     */
    @MainThread
    synchronized void saveConversationsToDisk() {
        ConversationInfosProtoDiskReadWriter conversationInfosProtoDiskReadWriter =
                getConversationInfosProtoDiskReadWriter();
        if (conversationInfosProtoDiskReadWriter != null) {
            conversationInfosProtoDiskReadWriter.saveConversationsImmediately(
                    new ArrayList<>(mConversationInfoMap.values()));
        }
    }

    @MainThread
    synchronized void addOrUpdate(@NonNull ConversationInfo conversationInfo) {
        updateConversationsInMemory(conversationInfo);
        scheduleUpdateConversationsOnDisk();
    }

    @MainThread
    @Nullable
    synchronized ConversationInfo deleteConversation(@NonNull String shortcutId) {
        ConversationInfo conversationInfo = mConversationInfoMap.remove(shortcutId);
        if (conversationInfo == null) {
            return null;
        }

        LocusId locusId = conversationInfo.getLocusId();
        if (locusId != null) {
            mLocusIdToShortcutIdMap.remove(locusId);
        }

        Uri contactUri = conversationInfo.getContactUri();
        if (contactUri != null) {
            mContactUriToShortcutIdMap.remove(contactUri);
        }

        String phoneNumber = conversationInfo.getContactPhoneNumber();
        if (phoneNumber != null) {
            mPhoneNumberToShortcutIdMap.remove(phoneNumber);
        }

        String notifChannelId = conversationInfo.getNotificationChannelId();
        if (notifChannelId != null) {
            mNotifChannelIdToShortcutIdMap.remove(notifChannelId);
        }
        scheduleUpdateConversationsOnDisk();
        return conversationInfo;
    }

    synchronized void forAllConversations(@NonNull Consumer<ConversationInfo> consumer) {
        for (ConversationInfo ci : mConversationInfoMap.values()) {
            consumer.accept(ci);
        }
    }

    @Nullable
    synchronized ConversationInfo getConversation(@Nullable String shortcutId) {
        return shortcutId != null ? mConversationInfoMap.get(shortcutId) : null;
    }

    @Nullable
    synchronized ConversationInfo getConversationByLocusId(@NonNull LocusId locusId) {
        return getConversation(mLocusIdToShortcutIdMap.get(locusId));
    }

    @Nullable
    synchronized ConversationInfo getConversationByContactUri(@NonNull Uri contactUri) {
        return getConversation(mContactUriToShortcutIdMap.get(contactUri));
    }

    @Nullable
    synchronized ConversationInfo getConversationByPhoneNumber(@NonNull String phoneNumber) {
        return getConversation(mPhoneNumberToShortcutIdMap.get(phoneNumber));
    }

    @Nullable
    ConversationInfo getConversationByNotificationChannelId(@NonNull String notifChannelId) {
        return getConversation(mNotifChannelIdToShortcutIdMap.get(notifChannelId));
    }

    synchronized void onDestroy() {
        mConversationInfoMap.clear();
        mContactUriToShortcutIdMap.clear();
        mLocusIdToShortcutIdMap.clear();
        mNotifChannelIdToShortcutIdMap.clear();
        mPhoneNumberToShortcutIdMap.clear();
        ConversationInfosProtoDiskReadWriter writer = getConversationInfosProtoDiskReadWriter();
        if (writer != null) {
            writer.deleteConversationsFile();
        }
    }

    @Nullable
    synchronized byte[] getBackupPayload() {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        DataOutputStream conversationInfosOut = new DataOutputStream(baos);
        for (ConversationInfo conversationInfo : mConversationInfoMap.values()) {
            byte[] backupPayload = conversationInfo.getBackupPayload();
            if (backupPayload == null) {
                continue;
            }
            try {
                conversationInfosOut.writeInt(backupPayload.length);
                conversationInfosOut.write(backupPayload);
            } catch (IOException e) {
                Slog.e(TAG, "Failed to write conversation info to backup payload.", e);
                return null;
            }
        }
        try {
            conversationInfosOut.writeInt(CONVERSATION_INFOS_END_TOKEN);
        } catch (IOException e) {
            Slog.e(TAG, "Failed to write conversation infos end token to backup payload.", e);
            return null;
        }
        return baos.toByteArray();
    }

    synchronized void restore(@NonNull byte[] payload) {
        DataInputStream in = new DataInputStream(new ByteArrayInputStream(payload));
        try {
            for (int conversationInfoSize = in.readInt();
                    conversationInfoSize != CONVERSATION_INFOS_END_TOKEN;
                    conversationInfoSize = in.readInt()) {
                byte[] conversationInfoPayload = new byte[conversationInfoSize];
                in.readFully(conversationInfoPayload, 0, conversationInfoSize);
                ConversationInfo conversationInfo = ConversationInfo.readFromBackupPayload(
                        conversationInfoPayload);
                if (conversationInfo != null) {
                    addOrUpdate(conversationInfo);
                }
            }
        } catch (IOException e) {
            Slog.e(TAG, "Failed to read conversation info from payload.", e);
        }
    }

    @MainThread
    private synchronized void updateConversationsInMemory(
            @NonNull ConversationInfo conversationInfo) {
        mConversationInfoMap.put(conversationInfo.getShortcutId(), conversationInfo);

        LocusId locusId = conversationInfo.getLocusId();
        if (locusId != null) {
            mLocusIdToShortcutIdMap.put(locusId, conversationInfo.getShortcutId());
        }

        Uri contactUri = conversationInfo.getContactUri();
        if (contactUri != null) {
            mContactUriToShortcutIdMap.put(contactUri, conversationInfo.getShortcutId());
        }

        String phoneNumber = conversationInfo.getContactPhoneNumber();
        if (phoneNumber != null) {
            mPhoneNumberToShortcutIdMap.put(phoneNumber, conversationInfo.getShortcutId());
        }

        String notifChannelId = conversationInfo.getNotificationChannelId();
        if (notifChannelId != null) {
            mNotifChannelIdToShortcutIdMap.put(notifChannelId, conversationInfo.getShortcutId());
        }
    }

    /** Schedules a dump of all conversations onto disk, overwriting existing values. */
    @MainThread
    private synchronized void scheduleUpdateConversationsOnDisk() {
        ConversationInfosProtoDiskReadWriter conversationInfosProtoDiskReadWriter =
                getConversationInfosProtoDiskReadWriter();
        if (conversationInfosProtoDiskReadWriter != null) {
            conversationInfosProtoDiskReadWriter.scheduleConversationsSave(
                    new ArrayList<>(mConversationInfoMap.values()));
        }
    }

    @Nullable
    private ConversationInfosProtoDiskReadWriter getConversationInfosProtoDiskReadWriter() {
        if (!mPackageDir.exists()) {
            Slog.e(TAG, "Package data directory does not exist: " + mPackageDir.getAbsolutePath());
            return null;
        }
        if (mConversationInfosProtoDiskReadWriter == null) {
            mConversationInfosProtoDiskReadWriter = new ConversationInfosProtoDiskReadWriter(
                    mPackageDir, CONVERSATIONS_FILE_NAME, mScheduledExecutorService);
        }
        return mConversationInfosProtoDiskReadWriter;
    }

    /** Reads and writes {@link ConversationInfo}s on disk. */
    private static class ConversationInfosProtoDiskReadWriter extends
            AbstractProtoDiskReadWriter<List<ConversationInfo>> {

        private final String mConversationInfoFileName;

        ConversationInfosProtoDiskReadWriter(@NonNull File rootDir,
                @NonNull String conversationInfoFileName,
                @NonNull ScheduledExecutorService scheduledExecutorService) {
            super(rootDir, scheduledExecutorService);
            mConversationInfoFileName = conversationInfoFileName;
        }

        @Override
        ProtoStreamWriter<List<ConversationInfo>> protoStreamWriter() {
            return (protoOutputStream, data) -> {
                for (ConversationInfo conversationInfo : data) {
                    long token = protoOutputStream.start(ConversationInfosProto.CONVERSATION_INFOS);
                    conversationInfo.writeToProto(protoOutputStream);
                    protoOutputStream.end(token);
                }
            };
        }

        @Override
        ProtoStreamReader<List<ConversationInfo>> protoStreamReader() {
            return protoInputStream -> {
                List<ConversationInfo> results = Lists.newArrayList();
                try {
                    while (protoInputStream.nextField() != ProtoInputStream.NO_MORE_FIELDS) {
                        if (protoInputStream.getFieldNumber()
                                != (int) ConversationInfosProto.CONVERSATION_INFOS) {
                            continue;
                        }
                        long token = protoInputStream.start(
                                ConversationInfosProto.CONVERSATION_INFOS);
                        ConversationInfo conversationInfo = ConversationInfo.readFromProto(
                                protoInputStream);
                        protoInputStream.end(token);
                        results.add(conversationInfo);
                    }
                } catch (IOException e) {
                    Slog.e(TAG, "Failed to read protobuf input stream.", e);
                }
                return results;
            };
        }

        /**
         * Schedules a flush of the specified conversations to disk.
         */
        @MainThread
        void scheduleConversationsSave(@NonNull List<ConversationInfo> conversationInfos) {
            scheduleSave(mConversationInfoFileName, conversationInfos);
        }

        /**
         * Saves the specified conversations immediately. This should be used when device is
         * powering off.
         */
        @MainThread
        void saveConversationsImmediately(@NonNull List<ConversationInfo> conversationInfos) {
            saveImmediately(mConversationInfoFileName, conversationInfos);
        }

        @WorkerThread
        void deleteConversationsFile() {
            delete(mConversationInfoFileName);
        }
    }
}
