package org.thoughtcrime.securesms.keyvalue;

import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;

import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;

import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;

/**
 * An replacement for {@link android.content.SharedPreferences} that stores key-value pairs in our
 * encrypted database.
 *
 * Implemented as a write-through cache that is safe to read and write to on the main thread.
 *
 * Writes are enqueued on a separate executor, but writes are finished up in
 * {@link SignalUncaughtExceptionHandler}, meaning all write should finish barring a native crash
 * or the system killing us unexpectedly (i.e. a force-stop).
 */
public final class KeyValueStore implements KeyValueReader {

  private static final String TAG = Log.tag(KeyValueStore.class);

  private final ExecutorService           executor;
  private final KeyValuePersistentStorage storage;

  private KeyValueDataSet dataSet;

  public KeyValueStore(@NonNull KeyValuePersistentStorage storage) {
    this.executor = SignalExecutors.newCachedSingleThreadExecutor("signal-KeyValueStore", ThreadUtil.PRIORITY_BACKGROUND_THREAD);
    this.storage  = storage;
  }

  @AnyThread
  @Override
  public synchronized byte[] getBlob(@NonNull String key, byte[] defaultValue) {
    initializeIfNecessary();
    return dataSet.getBlob(key, defaultValue);
  }

  @AnyThread
  @Override
  public synchronized boolean getBoolean(@NonNull String key, boolean defaultValue) {
    initializeIfNecessary();
    return dataSet.getBoolean(key, defaultValue);
  }

  @AnyThread
  @Override
  public synchronized float getFloat(@NonNull String key, float defaultValue) {
    initializeIfNecessary();
    return dataSet.getFloat(key, defaultValue);
  }

  @AnyThread
  @Override
  public synchronized int getInteger(@NonNull String key, int defaultValue) {
    initializeIfNecessary();
    return dataSet.getInteger(key, defaultValue);
  }

  @AnyThread
  @Override
  public synchronized long getLong(@NonNull String key, long defaultValue) {
    initializeIfNecessary();
    return dataSet.getLong(key, defaultValue);
  }

  @AnyThread
  @Override
  public synchronized String getString(@NonNull String key, String defaultValue) {
    initializeIfNecessary();
    return dataSet.getString(key, defaultValue);
  }

  @AnyThread
  @Override
  public synchronized boolean containsKey(@NonNull String key) {
    initializeIfNecessary();
    return dataSet.containsKey(key);
  }

  /**
   * @return A writer that allows writing and removing multiple entries in a single atomic
   *         transaction.
   */
  @AnyThread
  @NonNull Writer beginWrite() {
    return new Writer();
  }

  /**
   * @return A reader that lets you read from an immutable snapshot of the store, ensuring that data
   *         is consistent between reads. If you're only reading a single value, it is more
   *         efficient to use the various get* methods instead.
   */
  @AnyThread
  synchronized @NonNull KeyValueReader beginRead() {
    initializeIfNecessary();

    KeyValueDataSet copy = new KeyValueDataSet();
    copy.putAll(dataSet);

    return copy;
  }

  /**
   * Ensures that any pending writes (such as those made via {@link Writer#apply()}) are finished.
   */
  @AnyThread
  synchronized void blockUntilAllWritesFinished() {
    CountDownLatch latch = new CountDownLatch(1);

    executor.execute(latch::countDown);

    try {
      latch.await();
    } catch (InterruptedException e) {
      Log.w(TAG, "Failed to wait for all writes.");
    }
  }

  /**
   * Forces the store to re-fetch all of it's data from the database.
   */
  synchronized void resetCache() {
    dataSet = null;
    initializeIfNecessary();
  }

  private synchronized void write(@NonNull KeyValueDataSet newDataSet, @NonNull Collection<String> removes) {
    initializeIfNecessary();

    dataSet.putAll(newDataSet);
    dataSet.removeAll(removes);

    executor.execute(() -> storage.writeDataSet(newDataSet, removes));
  }

  private void initializeIfNecessary() {
    if (dataSet != null) return;
    this.dataSet = storage.getDataSet();
  }

  class Writer {
    private final KeyValueDataSet dataSet = new KeyValueDataSet();
    private final Set<String>     removes = new HashSet<>();

    @NonNull Writer putBlob(@NonNull String key, @Nullable byte[] value) {
      dataSet.putBlob(key, value);
      return this;
    }

    @NonNull Writer putBoolean(@NonNull String key, boolean value) {
      dataSet.putBoolean(key, value);
      return this;
    }

    @NonNull Writer putFloat(@NonNull String key, float value) {
      dataSet.putFloat(key, value);
      return this;
    }

    @NonNull Writer putInteger(@NonNull String key, int value) {
      dataSet.putInteger(key, value);
      return this;
    }

    @NonNull Writer putLong(@NonNull String key, long value) {
      dataSet.putLong(key, value);
      return this;
    }

    @NonNull Writer putString(@NonNull String key, String value) {
      dataSet.putString(key, value);
      return this;
    }

    @NonNull Writer remove(@NonNull String key) {
      removes.add(key);
      return this;
    }

    @AnyThread
    void apply() {
      for (String key : removes) {
        if (dataSet.containsKey(key)) {
          throw new IllegalStateException("Tried to remove a key while also setting it!");
        }
      }

      write(dataSet, removes);
    }

    @WorkerThread
    void commit() {
      apply();
      blockUntilAllWritesFinished();
    }
  }
}
