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

import android.annotation.NonNull;
import android.annotation.Nullable;
import android.os.PersistableBundle;
import android.util.AtomicFile;

import com.android.internal.annotations.GuardedBy;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.util.Preconditions;
import com.android.ondevicepersonalization.internal.util.LoggerFactory;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * A generic key-value datastore utilizing {@link android.util.AtomicFile} and {@link
 * android.os.PersistableBundle} to read/write a simple key/value map to file.
 * This class is thread-safe.
 * @hide
 */
public class BooleanFileDataStore {
    private static final LoggerFactory.Logger sLogger = LoggerFactory.getLogger();
    private static final String TAG = "BooleanFileDataStore";
    private final ReadWriteLock mReadWriteLock = new ReentrantReadWriteLock();
    private final Lock mReadLock = mReadWriteLock.readLock();
    private final Lock mWriteLock = mReadWriteLock.writeLock();

    private final AtomicFile mAtomicFile;
    private final Map<String, Boolean> mLocalMap = new HashMap<>();

    // TODO (b/300993651): make the datastore access singleton.
    // TODO (b/301131410): add version history feature.
    public BooleanFileDataStore(@NonNull String parentPath, @NonNull String filename) {
        Objects.requireNonNull(parentPath);
        Objects.requireNonNull(filename);
        Preconditions.checkStringNotEmpty(parentPath);
        Preconditions.checkStringNotEmpty(filename);
        mAtomicFile = new AtomicFile(new File(parentPath, filename));
    }

    /**
     * Loads data from the datastore file on disk.
     * @throws IOException if file read fails.
     */
    public void initialize() throws IOException {
        sLogger.d(TAG + ": reading from file " + mAtomicFile.getBaseFile());
        mReadLock.lock();
        try {
            readFromFile();
        } finally {
            mReadLock.unlock();
        }
    }

    /**
     * Stores a value to the datastore file, which is immediately committed.
     * @param key A non-null, non-empty String to store the {@code value}.
     * @param value A boolean to be stored.
     * @throws IOException if file write fails.
     * @throws NullPointerException if {@code key} is null.
     * @throws IllegalArgumentException if (@code key) is an empty string.
     */
    public void put(@NonNull String key, boolean value) throws IOException {
        Objects.requireNonNull(key);
        Preconditions.checkStringNotEmpty(key, "Key must not be empty.");

        mWriteLock.lock();
        try {
            mLocalMap.put(key, value);
            writeToFile();
        } finally {
            mWriteLock.unlock();
        }
    }

    /**
     * Retrieves a boolean value from the loaded datastore file.
     *
     * @param key A non-null, non-empty String key to fetch a value from.
     * @return The boolean value stored against a {@code key}, or null if it doesn't exist.
     * @throws IllegalArgumentException if {@code key} is an empty string.
     * @throws NullPointerException if {@code key} is null.
     */
    @Nullable
    public Boolean get(@NonNull String key) {
        Objects.requireNonNull(key);
        Preconditions.checkStringNotEmpty(key);

        mReadLock.lock();
        try {
            return mLocalMap.get(key);
        } finally {
            mReadLock.unlock();
        }
    }

    /**
     * Retrieves a {@link Set} of all keys loaded from the datastore file.
     *
     * @return A {@link Set} of {@link String} keys currently in the loaded datastore
     */
    @NonNull
    public Set<String> keySet() {
        mReadLock.lock();
        try {
            return Set.copyOf(mLocalMap.keySet());
        } finally {
            mReadLock.unlock();
        }
    }

    /**
     * Clears all entries from the datastore file and committed immediately.
     *
     * @throws IOException if file write fails.
     */
    public void clear() throws IOException {
        sLogger.d(TAG + ": clearing all entries from datastore");

        mWriteLock.lock();
        try {
            mLocalMap.clear();
            writeToFile();
        } finally {
            mWriteLock.unlock();
        }
    }

    @GuardedBy("mWriteLock")
    private void writeToFile() throws IOException {
        final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        final PersistableBundle persistableBundle = new PersistableBundle();
        for (Map.Entry<String, Boolean> entry: mLocalMap.entrySet()) {
            persistableBundle.putBoolean(entry.getKey(), entry.getValue());
        }

        persistableBundle.writeToStream(outputStream);

        FileOutputStream out = null;
        try {
            out = mAtomicFile.startWrite();
            out.write(outputStream.toByteArray());
            mAtomicFile.finishWrite(out);
        } catch (IOException e) {
            mAtomicFile.failWrite(out);
            sLogger.e(TAG + ": write to file " + mAtomicFile.getBaseFile() + " failed.");
            throw e;
        }
    }

    @GuardedBy("mReadLock")
    private void readFromFile() throws IOException {
        try {
            final ByteArrayInputStream inputStream = new ByteArrayInputStream(
                            mAtomicFile.readFully());
            final PersistableBundle persistableBundle = PersistableBundle.readFromStream(
                            inputStream);

            mLocalMap.clear();
            for (String key: persistableBundle.keySet()) {
                mLocalMap.put(key, persistableBundle.getBoolean(key));
            }
        } catch (FileNotFoundException e) {
            sLogger.d(TAG + ": file not found exception.");
            mLocalMap.clear();
        } catch (IOException e) {
            sLogger.e(TAG + ": read from " + mAtomicFile.getBaseFile() + " failed");
            throw e;
        }
    }

    /**
     * Delete the datastore file for testing.
     */
    @VisibleForTesting
    public void tearDownForTesting() {
        mWriteLock.lock();
        try {
            mAtomicFile.delete();
            mLocalMap.clear();
        } finally {
            mWriteLock.unlock();
        }
    }

    /**
     * Clear the loaded content from local map for testing.
     */
    @VisibleForTesting
    public void clearLocalMapForTesting() {
        mWriteLock.lock();
        mLocalMap.clear();
        mWriteLock.unlock();
    }
}