package org.thoughtcrime.securesms.database;

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;

import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;

import com.google.protobuf.InvalidProtocolBufferException;
import com.mobilecoin.lib.exceptions.SerializationException;

import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.payments.CryptoValueUtil;
import org.thoughtcrime.securesms.payments.Direction;
import org.thoughtcrime.securesms.payments.FailureReason;
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress;
import org.thoughtcrime.securesms.payments.Payee;
import org.thoughtcrime.securesms.payments.Payment;
import org.thoughtcrime.securesms.payments.State;
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.CursorUtil;
import org.thoughtcrime.securesms.util.SqlUtil;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.whispersystems.signalservice.api.payments.Money;

import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.UUID;

public final class PaymentDatabase extends Database {

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

  public static final String TABLE_NAME = "payments";

  private static final String ID           = "_id";
  private static final String PAYMENT_UUID = "uuid";
  private static final String RECIPIENT_ID = "recipient";
  private static final String ADDRESS      = "recipient_address";
  private static final String TIMESTAMP    = "timestamp";
  private static final String DIRECTION    = "direction";
  private static final String STATE        = "state";
  private static final String NOTE         = "note";
  private static final String AMOUNT       = "amount";
  private static final String FEE          = "fee";
  private static final String TRANSACTION  = "transaction_record";
  private static final String RECEIPT      = "receipt";
  private static final String PUBLIC_KEY   = "receipt_public_key";
  private static final String META_DATA    = "payment_metadata";
  private static final String FAILURE      = "failure_reason";
  private static final String BLOCK_INDEX  = "block_index";
  private static final String BLOCK_TIME   = "block_timestamp";
  private static final String SEEN         = "seen";

  public static final String CREATE_TABLE =
    "CREATE TABLE " + TABLE_NAME + "(" + ID           + " INTEGER PRIMARY KEY, " +
                                         PAYMENT_UUID + " TEXT DEFAULT NULL, " +
                                         RECIPIENT_ID + " INTEGER DEFAULT 0, " +
                                         ADDRESS      + " TEXT DEFAULT NULL, " +
                                         TIMESTAMP    + " INTEGER, " +
                                         NOTE         + " TEXT DEFAULT NULL, " +
                                         DIRECTION    + " INTEGER, " +
                                         STATE        + " INTEGER, " +
                                         FAILURE      + " INTEGER, " +
                                         AMOUNT       + " BLOB NOT NULL, " +
                                         FEE          + " BLOB NOT NULL, " +
                                         TRANSACTION  + " BLOB DEFAULT NULL, " +
                                         RECEIPT      + " BLOB DEFAULT NULL, " +
                                         META_DATA    + " BLOB DEFAULT NULL, " +
                                         PUBLIC_KEY   + " TEXT DEFAULT NULL, " +
                                         BLOCK_INDEX  + " INTEGER DEFAULT 0, " +
                                         BLOCK_TIME   + " INTEGER DEFAULT 0, " +
                                         SEEN         + " INTEGER, " +
                                         "UNIQUE(" + PAYMENT_UUID + ") ON CONFLICT ABORT)";

  public static final String[] CREATE_INDEXES = {
    "CREATE INDEX IF NOT EXISTS timestamp_direction_index ON " + TABLE_NAME + " (" + TIMESTAMP + ", " + DIRECTION + ");",
    "CREATE INDEX IF NOT EXISTS timestamp_index ON " + TABLE_NAME + " (" + TIMESTAMP + ");",
    "CREATE UNIQUE INDEX IF NOT EXISTS receipt_public_key_index ON " + TABLE_NAME + " (" + PUBLIC_KEY + ");"
  };

  private final MutableLiveData<Object> changeSignal;

  PaymentDatabase(@NonNull Context context, @NonNull SignalDatabase databaseHelper) {
    super(context, databaseHelper);

    this.changeSignal = new MutableLiveData<>(new Object());
  }

  @WorkerThread
  public void createIncomingPayment(@NonNull UUID uuid,
                                    @Nullable RecipientId fromRecipient,
                                    long timestamp,
                                    @NonNull String note,
                                    @NonNull Money amount,
                                    @NonNull Money fee,
                                    @NonNull byte[] receipt)
      throws PublicKeyConflictException, SerializationException
  {
    create(uuid, fromRecipient, null, timestamp, 0, note, Direction.RECEIVED, State.SUBMITTED, amount, fee, null, receipt, null, false);
  }

  @WorkerThread
  public void createOutgoingPayment(@NonNull UUID uuid,
                                    @Nullable RecipientId toRecipient,
                                    @NonNull MobileCoinPublicAddress publicAddress,
                                    long timestamp,
                                    @NonNull String note,
                                    @NonNull Money amount)
  {
    try {
      create(uuid, toRecipient, publicAddress, timestamp, 0, note, Direction.SENT, State.INITIAL, amount, amount.toZero(), null, null, null, true);
    } catch (PublicKeyConflictException e) {
      Log.w(TAG, "Tried to create payment but the public key appears already in the database", e);
      throw new IllegalArgumentException(e);
    } catch (SerializationException e) {
      throw new IllegalArgumentException(e);
    }
  }

  /**
   * Inserts a payment in its final successful state.
   * <p>
   * This is for when a linked device has told us about the payment only.
   */
  @WorkerThread
  public void createSuccessfulPayment(@NonNull UUID uuid,
                                      @Nullable RecipientId toRecipient,
                                      @NonNull MobileCoinPublicAddress publicAddress,
                                      long timestamp,
                                      long blockIndex,
                                      @NonNull String note,
                                      @NonNull Money amount,
                                      @NonNull Money fee,
                                      @NonNull byte[] receipt,
                                      @NonNull PaymentMetaData metaData)
    throws SerializationException
  {
    try {
      create(uuid, toRecipient, publicAddress, timestamp, blockIndex, note, Direction.SENT, State.SUCCESSFUL, amount, fee, null, receipt, metaData, true);
    } catch (PublicKeyConflictException e) {
      Log.w(TAG, "Tried to create payment but the public key appears already in the database", e);
      throw new AssertionError(e);
    }
  }

  @WorkerThread
  public void createDefrag(@NonNull UUID uuid,
                           @Nullable RecipientId self,
                           @NonNull MobileCoinPublicAddress selfPublicAddress,
                           long timestamp,
                           @NonNull Money fee,
                           @NonNull byte[] transaction,
                           @NonNull byte[] receipt)
  {
    try {
      create(uuid, self, selfPublicAddress, timestamp, 0, "", Direction.SENT, State.SUBMITTED, fee.toZero(), fee, transaction, receipt, null, true);
    } catch (PublicKeyConflictException e) {
      Log.w(TAG, "Tried to create payment but the public key appears already in the database", e);
      throw new AssertionError(e);
    } catch (SerializationException e) {
      throw new IllegalArgumentException(e);
    }
  }

  @WorkerThread
  private void create(@NonNull UUID uuid,
                      @Nullable RecipientId recipientId,
                      @Nullable MobileCoinPublicAddress publicAddress,
                      long timestamp,
                      long blockIndex,
                      @NonNull String note,
                      @NonNull Direction direction,
                      @NonNull State state,
                      @NonNull Money amount,
                      @NonNull Money fee,
                      @Nullable byte[] transaction,
                      @Nullable byte[] receipt,
                      @Nullable PaymentMetaData metaData,
                      boolean seen)
      throws PublicKeyConflictException, SerializationException
  {
    if (recipientId == null && publicAddress == null) {
      throw new AssertionError();
    }

    if (amount.isNegative()) {
      throw new AssertionError();
    }

    if (fee.isNegative()) {
      throw new AssertionError();
    }

    SQLiteDatabase database = databaseHelper.getSignalWritableDatabase();
    ContentValues  values   = new ContentValues(15);

    values.put(PAYMENT_UUID, uuid.toString());
    if (recipientId == null || recipientId.isUnknown()) {
      values.put(RECIPIENT_ID, 0);
    } else {
      values.put(RECIPIENT_ID, recipientId.serialize());
    }
    if (publicAddress == null) {
      values.putNull(ADDRESS);
    } else {
      values.put(ADDRESS, publicAddress.getPaymentAddressBase58());
    }
    values.put(TIMESTAMP, timestamp);
    values.put(BLOCK_INDEX, blockIndex);
    values.put(NOTE, note);
    values.put(DIRECTION, direction.serialize());
    values.put(STATE, state.serialize());
    values.put(AMOUNT, CryptoValueUtil.moneyToCryptoValue(amount).toByteArray());
    values.put(FEE, CryptoValueUtil.moneyToCryptoValue(fee).toByteArray());
    if (transaction != null) {
      values.put(TRANSACTION, transaction);
    } else {
      values.putNull(TRANSACTION);
    }
    if (receipt != null) {
      values.put(RECEIPT, receipt);
      values.put(PUBLIC_KEY, Base64.encodeBytes(PaymentMetaDataUtil.receiptPublic(PaymentMetaDataUtil.fromReceipt(receipt))));
    } else {
      values.putNull(RECEIPT);
      values.putNull(PUBLIC_KEY);
    }
    if (metaData != null) {
      values.put(META_DATA, metaData.toByteArray());
    } else {
      values.put(META_DATA, PaymentMetaDataUtil.fromReceiptAndTransaction(receipt, transaction).toByteArray());
    }
    values.put(SEEN, seen ? 1 : 0);

    long inserted = database.insert(TABLE_NAME, null, values);

    if (inserted == -1) {
      throw new PublicKeyConflictException();
    }

    notifyChanged(uuid);
  }

  public void deleteAll() {
    SQLiteDatabase database = databaseHelper.getSignalWritableDatabase();
    database.delete(TABLE_NAME, null, null);
    Log.i(TAG, "Deleted all records");
  }

  @WorkerThread
  public boolean delete(@NonNull UUID uuid) {
    SQLiteDatabase database = databaseHelper.getSignalWritableDatabase();
    String         where    = PAYMENT_UUID + " = ?";
    String[]       args     = {uuid.toString()};
    int            deleted;

    database.beginTransaction();
    try {
      deleted = database.delete(TABLE_NAME, where, args);

      if (deleted > 1) {
        Log.w(TAG, "More than one row matches criteria");
        throw new AssertionError();
      }
      database.setTransactionSuccessful();
    } finally {
      database.endTransaction();
    }

    if (deleted > 0) {
      notifyChanged(uuid);
    }

    return deleted > 0;
  }

  @WorkerThread
  public @NonNull List<PaymentTransaction> getAll() {
    SQLiteDatabase           database = databaseHelper.getSignalReadableDatabase();
    List<PaymentTransaction> result   = new LinkedList<>();

    try (Cursor cursor = database.query(TABLE_NAME, null, null, null, null, null, TIMESTAMP + " DESC")) {
      while (cursor.moveToNext()) {
        result.add(readPayment(cursor));
      }
    }

    return result;
  }

  @WorkerThread
  public void markAllSeen() {
    SQLiteDatabase database         = databaseHelper.getSignalWritableDatabase();
    ContentValues  values           = new ContentValues(1);
    List<UUID>     unseenIds        = new LinkedList<>();
    String[]       unseenProjection = SqlUtil.buildArgs(PAYMENT_UUID);
    String         unseenWhile      = SEEN + " != ?";
    String[]       unseenArgs       = SqlUtil.buildArgs("1");
    int            updated          = -1;

    values.put(SEEN, 1);

    try {
      database.beginTransaction();

      try (Cursor cursor = database.query(TABLE_NAME, unseenProjection, unseenWhile, unseenArgs, null, null, null)) {
        while (cursor != null && cursor.moveToNext()) {
          unseenIds.add(UUID.fromString(CursorUtil.requireString(cursor, PAYMENT_UUID)));
        }
      }

      if (!unseenIds.isEmpty()) {
        updated = database.update(TABLE_NAME, values, null, null);
      }

      database.setTransactionSuccessful();
    } finally {
      database.endTransaction();
    }

    if (updated > 0) {
      for (final UUID unseenId : unseenIds) {
        notifyUuidChanged(unseenId);
      }

      notifyChanged();
    }
  }

  @WorkerThread
  public void markPaymentSeen(@NonNull UUID uuid) {
    SQLiteDatabase database = databaseHelper.getSignalWritableDatabase();
    ContentValues  values   = new ContentValues(1);
    String         where    = PAYMENT_UUID + " = ?";
    String[]       args     = {uuid.toString()};

    values.put(SEEN, 1);
    int updated = database.update(TABLE_NAME, values, where, args);

    if (updated > 0) {
      notifyChanged(uuid);
    }
  }

  @WorkerThread
  public @NonNull List<PaymentTransaction> getUnseenPayments() {
    SQLiteDatabase           db      = databaseHelper.getSignalReadableDatabase();
    String                   query   = SEEN + " = 0 AND " + STATE + " = " + State.SUCCESSFUL.serialize();
    List<PaymentTransaction> results = new LinkedList<>();

    try (Cursor cursor = db.query(TABLE_NAME, null, query, null, null, null, null)) {
      while (cursor.moveToNext()) {
        results.add(readPayment(cursor));
      }
    }

    return results;
  }

  @WorkerThread
  public @Nullable PaymentTransaction getPayment(@NonNull UUID uuid) {
    SQLiteDatabase database = databaseHelper.getSignalReadableDatabase();
    String         select   = PAYMENT_UUID + " = ?";
    String[]       args     = {uuid.toString()};

    try (Cursor cursor = database.query(TABLE_NAME, null, select, args, null, null, null)) {
      if (cursor.moveToNext()) {
        PaymentTransaction payment = readPayment(cursor);

        if (cursor.moveToNext()) {
          throw new AssertionError("Multiple records for one UUID");
        }

        return payment;
      } else {
        return null;
      }
    }
  }

  @AnyThread
  public @NonNull LiveData<List<PaymentTransaction>> getAllLive() {
    return LiveDataUtil.mapAsync(changeSignal, change -> getAll());
  }

  public boolean markPaymentSubmitted(@NonNull UUID uuid,
                                      @NonNull byte[] transaction,
                                      @NonNull byte[] receipt,
                                      @NonNull Money fee)
      throws PublicKeyConflictException
  {
    SQLiteDatabase database  = databaseHelper.getSignalWritableDatabase();
    String         where     = PAYMENT_UUID + " = ?";
    String[]       whereArgs = {uuid.toString()};
    int            updated;
    ContentValues  values    = new ContentValues(6);

    values.put(STATE, State.SUBMITTED.serialize());
    values.put(TRANSACTION, transaction);
    values.put(RECEIPT, receipt);
    try {
      values.put(PUBLIC_KEY, Base64.encodeBytes(PaymentMetaDataUtil.receiptPublic(PaymentMetaDataUtil.fromReceipt(receipt))));
      values.put(META_DATA, PaymentMetaDataUtil.fromReceiptAndTransaction(receipt, transaction).toByteArray());
    } catch (SerializationException e) {
      throw new IllegalArgumentException(e);
    }
    values.put(FEE, CryptoValueUtil.moneyToCryptoValue(fee).toByteArray());

    database.beginTransaction();
    try {
      updated = database.update(TABLE_NAME, values, where, whereArgs);

      if (updated == -1) {
        throw new PublicKeyConflictException();
      }

      if (updated > 1) {
        Log.w(TAG, "More than one row matches criteria");
        throw new AssertionError();
      }
      database.setTransactionSuccessful();
    } finally {
      database.endTransaction();
    }

    if (updated > 0) {
      notifyChanged(uuid);
    }

    return updated > 0;
  }

  public boolean markPaymentSuccessful(@NonNull UUID uuid, long blockIndex) {
    return markPayment(uuid, State.SUCCESSFUL, null, null, blockIndex);
  }

  public boolean markReceivedPaymentSuccessful(@NonNull UUID uuid, @NonNull Money amount, long blockIndex) {
    return markPayment(uuid, State.SUCCESSFUL, amount, null, blockIndex);
  }

  public boolean markPaymentFailed(@NonNull UUID uuid, @NonNull FailureReason failureReason) {
    return markPayment(uuid, State.FAILED, null, failureReason, null);
  }

  private boolean markPayment(@NonNull UUID uuid,
                              @NonNull State state,
                              @Nullable Money amount,
                              @Nullable FailureReason failureReason,
                              @Nullable Long blockIndex)
  {
    SQLiteDatabase database  = databaseHelper.getSignalWritableDatabase();
    String         where     = PAYMENT_UUID + " = ?";
    String[]       whereArgs = {uuid.toString()};
    int            updated;
    ContentValues  values    = new ContentValues(3);

    values.put(STATE, state.serialize());

    if (amount != null) {
      values.put(AMOUNT, CryptoValueUtil.moneyToCryptoValue(amount).toByteArray());
    }

    if (state == State.FAILED) {
      values.put(FAILURE, failureReason != null ? failureReason.serialize()
                                                : FailureReason.UNKNOWN.serialize());
    } else {
      if (failureReason != null) {
        throw new AssertionError();
      }
      values.putNull(FAILURE);
    }

    if (blockIndex != null) {
      values.put(BLOCK_INDEX, blockIndex);
    }

    database.beginTransaction();
    try {
      updated = database.update(TABLE_NAME, values, where, whereArgs);

      if (updated > 1) {
        Log.w(TAG, "More than one row matches criteria");
        throw new AssertionError();
      }
      database.setTransactionSuccessful();
    } finally {
      database.endTransaction();
    }

    if (updated > 0) {
      notifyChanged(uuid);
    }

    return updated > 0;
  }

  public boolean updateBlockDetails(@NonNull UUID uuid,
                                    long blockIndex,
                                    long blockTimestamp)
  {
    SQLiteDatabase database  = databaseHelper.getSignalWritableDatabase();
    String         where     = PAYMENT_UUID + " = ?";
    String[]       whereArgs = {uuid.toString()};
    int            updated;
    ContentValues  values    = new ContentValues(2);

    values.put(BLOCK_INDEX, blockIndex);
    values.put(BLOCK_TIME, blockTimestamp);

    database.beginTransaction();
    try {
      updated = database.update(TABLE_NAME, values, where, whereArgs);

      if (updated > 1) {
        Log.w(TAG, "More than one row matches criteria");
        throw new AssertionError();
      }
      database.setTransactionSuccessful();
    } finally {
      database.endTransaction();
    }

    if (updated > 0) {
      notifyChanged(uuid);
    }

    return updated > 0;
  }

  private static @NonNull PaymentTransaction readPayment(@NonNull Cursor cursor) {
    return new PaymentTransaction(UUID.fromString(CursorUtil.requireString(cursor, PAYMENT_UUID)),
                                  getRecipientId(cursor),
                                  MobileCoinPublicAddress.fromBase58NullableOrThrow(CursorUtil.requireString(cursor, ADDRESS)),
                                  CursorUtil.requireLong(cursor, TIMESTAMP),
                                  Direction.deserialize(CursorUtil.requireInt(cursor, DIRECTION)),
                                  State.deserialize(CursorUtil.requireInt(cursor, STATE)),
                                  FailureReason.deserialize(CursorUtil.requireInt(cursor, FAILURE)),
                                  CursorUtil.requireString(cursor, NOTE),
                                  getMoneyValue(CursorUtil.requireBlob(cursor, AMOUNT)),
                                  getMoneyValue(CursorUtil.requireBlob(cursor, FEE)),
                                  CursorUtil.requireBlob(cursor, TRANSACTION),
                                  CursorUtil.requireBlob(cursor, RECEIPT),
                                  PaymentMetaDataUtil.parseOrThrow(CursorUtil.requireBlob(cursor, META_DATA)),
                                  CursorUtil.requireLong(cursor, BLOCK_INDEX),
                                  CursorUtil.requireLong(cursor, BLOCK_TIME),
                                  CursorUtil.requireBoolean(cursor, SEEN));
  }

  private static @Nullable RecipientId getRecipientId(@NonNull Cursor cursor) {
    long id = CursorUtil.requireLong(cursor, RECIPIENT_ID);
    if (id == 0) return null;
    return RecipientId.from(id);
  }

  private static @NonNull Money getMoneyValue(@NonNull byte[] blob) {
    try {
      CryptoValue cryptoValue = CryptoValue.parseFrom(blob);
      return CryptoValueUtil.cryptoValueToMoney(cryptoValue);
    } catch (InvalidProtocolBufferException e) {
      throw new AssertionError(e);
    }
  }

  /**
   * notifyChanged will alert the database observer for two events:
   *
   * 1. It will alert the global payments observer that something changed
   * 2. It will alert the uuid specific observer that something will change.
   *
   * You should not call this in a tight loop, opting to call notifyUuidChanged instead.
   */
  private void notifyChanged(@Nullable UUID uuid) {
    notifyChanged();
    notifyUuidChanged(uuid);
  }

  /**
   * Notifies the global payments observer that something changed.
   */
  private void notifyChanged() {
    changeSignal.postValue(new Object());
    ApplicationDependencies.getDatabaseObserver().notifyAllPaymentsListeners();
  }

  /**
   * Notify the database observer of a change for a specific uuid. Does not trigger
   * the global payments observer.
   */
  private void notifyUuidChanged(@Nullable UUID uuid) {
    if (uuid != null) {
      ApplicationDependencies.getDatabaseObserver().notifyPaymentListeners(uuid);
    }
  }

  public static final class PaymentTransaction implements Payment {
    private final UUID            uuid;
    private final Payee           payee;
    private final long            timestamp;
    private final Direction       direction;
    private final State           state;
    private final FailureReason   failureReason;
    private final String          note;
    private final Money           amount;
    private final Money           fee;
    private final byte[]          transaction;
    private final byte[]          receipt;
    private final PaymentMetaData paymentMetaData;
    private final Long            blockIndex;
    private final long            blockTimestamp;
    private final boolean         seen;

    PaymentTransaction(@NonNull UUID uuid,
                       @Nullable RecipientId recipientId,
                       @Nullable MobileCoinPublicAddress publicAddress,
                       long timestamp,
                       @NonNull Direction direction,
                       @NonNull State state,
                       @Nullable FailureReason failureReason,
                       @NonNull String note,
                       @NonNull Money amount,
                       @NonNull Money fee,
                       @Nullable byte[] transaction,
                       @Nullable byte[] receipt,
                       @NonNull PaymentMetaData paymentMetaData,
                       @Nullable Long blockIndex,
                       long blockTimestamp,
                       boolean seen)
    {
      this.uuid            = uuid;
      this.paymentMetaData = paymentMetaData;
      this.payee           = fromPaymentTransaction(recipientId, publicAddress);
      this.timestamp       = timestamp;
      this.direction       = direction;
      this.state           = state;
      this.failureReason   = failureReason;
      this.note            = note;
      this.amount          = amount;
      this.fee             = fee;
      this.transaction     = transaction;
      this.receipt         = receipt;
      this.blockIndex      = blockIndex;
      this.blockTimestamp  = blockTimestamp;
      this.seen            = seen;

      if (amount.isNegative()) {
        throw new AssertionError();
      }
    }

    @Override
    public @NonNull UUID getUuid() {
      return uuid;
    }

    @Override
    public @NonNull Payee getPayee() {
      return payee;
    }

    @Override
    public long getBlockIndex() {
      return blockIndex;
    }

    @Override
    public long getBlockTimestamp() {
      return blockTimestamp;
    }

    @Override
    public long getTimestamp() {
      return timestamp;
    }

    @Override
    public @NonNull Direction getDirection() {
      return direction;
    }

    @Override
    public @NonNull State getState() {
      return state;
    }

    @Override
    public @Nullable FailureReason getFailureReason() {
      return failureReason;
    }

    @Override
    public @NonNull String getNote() {
      return note;
    }

    @Override
    public @NonNull Money getAmount() {
      return amount;
    }

    @Override
    public @NonNull Money getFee() {
      return fee;
    }

    @Override
    public @NonNull PaymentMetaData getPaymentMetaData() {
      return paymentMetaData;
    }

    @Override
    public boolean isSeen() {
      return seen;
    }

    public @Nullable byte[] getTransaction() {
      return transaction;
    }

    public @Nullable byte[] getReceipt() {
      return receipt;
    }

    @Override
    public boolean equals(@Nullable Object o) {
      if (this == o) return true;
      if (!(o instanceof PaymentTransaction)) return false;

      final PaymentTransaction other = (PaymentTransaction) o;

      return timestamp == other.timestamp &&
             uuid.equals(other.uuid) &&
             payee.equals(other.payee) &&
             direction == other.direction &&
             state == other.state &&
             note.equals(other.note) &&
             amount.equals(other.amount) &&
             Arrays.equals(transaction, other.transaction) &&
             Arrays.equals(receipt, other.receipt) &&
             paymentMetaData.equals(other.paymentMetaData);
    }

    @Override
    public int hashCode() {
      int result = uuid.hashCode();
      result = 31 * result + payee.hashCode();
      result = 31 * result + (int) (timestamp ^ (timestamp >>> 32));
      result = 31 * result + direction.hashCode();
      result = 31 * result + state.hashCode();
      result = 31 * result + note.hashCode();
      result = 31 * result + amount.hashCode();
      result = 31 * result + Arrays.hashCode(transaction);
      result = 31 * result + Arrays.hashCode(receipt);
      result = 31 * result + paymentMetaData.hashCode();
      return result;
    }
  }

  private static @NonNull Payee fromPaymentTransaction(@Nullable RecipientId recipientId, @Nullable MobileCoinPublicAddress publicAddress) {
    if (recipientId == null && publicAddress == null) {
      throw new AssertionError();
    }

    if (recipientId != null) {
      return Payee.fromRecipientAndAddress(recipientId, publicAddress);
    } else {
      return new Payee(publicAddress);
    }
  }

  public final class PublicKeyConflictException extends Exception {
    private PublicKeyConflictException() {}
  }
}
