package org.stellar.sdk;

import com.google.common.base.Objects;
import org.stellar.sdk.xdr.*;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

/**
 * Represents <a href="https://www.stellar.org/developers/learn/concepts/transactions.html" target="_blank">Transaction</a> in Stellar network.
 */
public class Transaction extends AbstractTransaction {
  private final long mFee;
  private final String mSourceAccount;
  private final long mSequenceNumber;
  private final Operation[] mOperations;
  private final Memo mMemo;
  private final TimeBounds mTimeBounds;
  private EnvelopeType envelopeType = EnvelopeType.ENVELOPE_TYPE_TX_V0;

  Transaction(
          String sourceAccount,
          long fee,
          long sequenceNumber,
          Operation[] operations,
          Memo memo,
          TimeBounds timeBounds,
          Network network
  ) {
    super(network);
    this.mSourceAccount = checkNotNull(sourceAccount, "sourceAccount cannot be null");
    this.mSequenceNumber = checkNotNull(sequenceNumber, "sequenceNumber cannot be null");
    this.mOperations = checkNotNull(operations, "operations cannot be null");
    checkArgument(operations.length > 0, "At least one operation required");

    this.mFee = fee;
    this.mMemo = memo != null ? memo : Memo.none();
    this.mTimeBounds = timeBounds;
  }

  // setEnvelopeType is only used in tests which is why this method is package protected
  void setEnvelopeType(EnvelopeType envelopeType) {
    this.envelopeType = envelopeType;
  }

  @Override
  public byte[] signatureBase() {
    try {
      TransactionSignaturePayload payload = new TransactionSignaturePayload();
      TransactionSignaturePayload.TransactionSignaturePayloadTaggedTransaction taggedTransaction = new TransactionSignaturePayload.TransactionSignaturePayloadTaggedTransaction();
      taggedTransaction.setDiscriminant(EnvelopeType.ENVELOPE_TYPE_TX);
      taggedTransaction.setTx(this.toV1Xdr());
      Hash hash = new Hash();
      hash.setHash(mNetwork.getNetworkId());
      payload.setNetworkId(hash);
      payload.setTaggedTransaction(taggedTransaction);
      ByteArrayOutputStream txOutputStream = new ByteArrayOutputStream();
      XdrDataOutputStream xdrOutputStream = new XdrDataOutputStream(txOutputStream);
      payload.encode(xdrOutputStream);
      return txOutputStream.toByteArray();
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  }


  public String getSourceAccount() {
    return mSourceAccount;
  }

  public long getSequenceNumber() {
    return mSequenceNumber;
  }

  public Memo getMemo() {
    return mMemo;
  }
  
  /**
   * @return TimeBounds, or null (representing no time restrictions)
   */
  public TimeBounds getTimeBounds() {
    return mTimeBounds;
  }

  /**
   * Returns fee paid for transaction in stroops (1 stroop = 0.0000001 XLM).
   */
  public long getFee() {
    return mFee;
  }

  /**
   * Returns operations in this transaction.
   */
  public Operation[] getOperations() {
    return mOperations;
  }

  /**
   * Generates Transaction XDR object.
   */
  private TransactionV0 toXdr() {
    // fee
    Uint32 fee = new Uint32();
    fee.setUint32((int)mFee);
    // sequenceNumber
    Int64 sequenceNumberUint = new Int64();
    sequenceNumberUint.setInt64(mSequenceNumber);
    SequenceNumber sequenceNumber = new SequenceNumber();
    sequenceNumber.setSequenceNumber(sequenceNumberUint);
    // operations
    org.stellar.sdk.xdr.Operation[] operations = new org.stellar.sdk.xdr.Operation[mOperations.length];
    for (int i = 0; i < mOperations.length; i++) {
      operations[i] = mOperations[i].toXdr();
    }
    // ext
    TransactionV0.TransactionV0Ext ext = new TransactionV0.TransactionV0Ext();
    ext.setDiscriminant(0);

    TransactionV0 transaction = new TransactionV0();
    transaction.setFee(fee);
    transaction.setSeqNum(sequenceNumber);
    transaction.setSourceAccountEd25519(StrKey.encodeToXDRAccountId(this.mSourceAccount).getAccountID().getEd25519());
    transaction.setOperations(operations);
    transaction.setMemo(mMemo.toXdr());
    transaction.setTimeBounds(mTimeBounds == null ? null : mTimeBounds.toXdr());
    transaction.setExt(ext);
    return transaction;
  }

  private org.stellar.sdk.xdr.Transaction toV1Xdr() {

    // fee
    Uint32 fee = new Uint32();
    fee.setUint32((int)mFee);
    // sequenceNumber
    Int64 sequenceNumberUint = new Int64();
    sequenceNumberUint.setInt64(mSequenceNumber);
    SequenceNumber sequenceNumber = new SequenceNumber();
    sequenceNumber.setSequenceNumber(sequenceNumberUint);
    // operations
    org.stellar.sdk.xdr.Operation[] operations = new org.stellar.sdk.xdr.Operation[mOperations.length];
    for (int i = 0; i < mOperations.length; i++) {
      operations[i] = mOperations[i].toXdr();
    }
    // ext
    org.stellar.sdk.xdr.Transaction.TransactionExt ext = new org.stellar.sdk.xdr.Transaction.TransactionExt();
    ext.setDiscriminant(0);


    org.stellar.sdk.xdr.Transaction v1Tx = new org.stellar.sdk.xdr.Transaction();
    v1Tx.setFee(fee);
    v1Tx.setSeqNum(sequenceNumber);
    v1Tx.setSourceAccount(StrKey.encodeToXDRMuxedAccount(mSourceAccount));
    v1Tx.setOperations(operations);
    v1Tx.setMemo(mMemo.toXdr());
    v1Tx.setTimeBounds(mTimeBounds == null ? null : mTimeBounds.toXdr());
    v1Tx.setExt(ext);

    return v1Tx;
  }

  public static Transaction fromV0EnvelopeXdr(TransactionV0Envelope envelope, Network network) {
    int mFee = envelope.getTx().getFee().getUint32();
    Long mSequenceNumber = envelope.getTx().getSeqNum().getSequenceNumber().getInt64();
    Memo mMemo = Memo.fromXdr(envelope.getTx().getMemo());
    TimeBounds mTimeBounds = TimeBounds.fromXdr(envelope.getTx().getTimeBounds());

    Operation[] mOperations = new Operation[envelope.getTx().getOperations().length];
    for (int i = 0; i < envelope.getTx().getOperations().length; i++) {
      mOperations[i] = Operation.fromXdr(envelope.getTx().getOperations()[i]);
    }

    Transaction transaction = new Transaction(
        StrKey.encodeStellarAccountId(envelope.getTx().getSourceAccountEd25519().getUint256()),
        mFee,
        mSequenceNumber,
        mOperations,
        mMemo,
        mTimeBounds,
        network
    );

    for (DecoratedSignature signature : envelope.getSignatures()) {
      transaction.mSignatures.add(signature);
    }

    return transaction;
  }

  public static Transaction fromV1EnvelopeXdr(TransactionV1Envelope envelope, Network network) {
    int mFee = envelope.getTx().getFee().getUint32();
    Long mSequenceNumber = envelope.getTx().getSeqNum().getSequenceNumber().getInt64();
    Memo mMemo = Memo.fromXdr(envelope.getTx().getMemo());
    TimeBounds mTimeBounds = TimeBounds.fromXdr(envelope.getTx().getTimeBounds());

    Operation[] mOperations = new Operation[envelope.getTx().getOperations().length];
    for (int i = 0; i < envelope.getTx().getOperations().length; i++) {
      mOperations[i] = Operation.fromXdr(envelope.getTx().getOperations()[i]);
    }

    Transaction transaction = new Transaction(
        StrKey.encodeStellarAccountId(StrKey.muxedAccountToAccountId(envelope.getTx().getSourceAccount())),
        mFee,
        mSequenceNumber,
        mOperations,
        mMemo,
        mTimeBounds,
        network
    );

    for (DecoratedSignature signature : envelope.getSignatures()) {
      transaction.mSignatures.add(signature);
    }

    return transaction;
  }

  /**
   * Generates TransactionEnvelope XDR object.
   */
  @Override
  public TransactionEnvelope toEnvelopeXdr() {
    TransactionEnvelope xdr = new TransactionEnvelope();
    DecoratedSignature[] signatures = new DecoratedSignature[mSignatures.size()];
    signatures = mSignatures.toArray(signatures);

    if (this.envelopeType == EnvelopeType.ENVELOPE_TYPE_TX) {
      TransactionV1Envelope v1Envelope = new TransactionV1Envelope();
      xdr.setDiscriminant(EnvelopeType.ENVELOPE_TYPE_TX);
      v1Envelope.setTx(this.toV1Xdr());
      v1Envelope.setSignatures(signatures);
      xdr.setV1(v1Envelope);
    } else if (this.envelopeType == EnvelopeType.ENVELOPE_TYPE_TX_V0) {
      TransactionV0Envelope v0Envelope = new TransactionV0Envelope();
      xdr.setDiscriminant(EnvelopeType.ENVELOPE_TYPE_TX_V0);
      v0Envelope.setTx(this.toXdr());
      v0Envelope.setSignatures(signatures);
      xdr.setV0(v0Envelope);
    } else {
      throw new RuntimeException("invalid envelope type: "+this.envelopeType);
    }

    return xdr;
  }

  /**
   * Builds a new Transaction object.
   */
  public static class Builder {
    private final TransactionBuilderAccount mSourceAccount;
    private Memo mMemo;
    private TimeBounds mTimeBounds;
    List<Operation> mOperations;
    private boolean timeoutSet;
    private Integer mBaseFee;
    private Network mNetwork;

    public static final long TIMEOUT_INFINITE = 0;

    /**
     * Construct a new transaction builder.
     * @param sourceAccount The source account for this transaction. This account is the account
     * who will use a sequence number. When build() is called, the account object's sequence number
     * will be incremented.
     */
    public Builder(TransactionBuilderAccount sourceAccount, Network network) {
      checkNotNull(sourceAccount, "sourceAccount cannot be null");
      mSourceAccount = sourceAccount;
      mOperations = Collections.synchronizedList(new ArrayList<Operation>());
      mNetwork = checkNotNull(network, "Network cannot be null");
    }

    public int getOperationsCount() {
      return mOperations.size();
    }

    /**
     * Adds a new <a href="https://www.stellar.org/developers/learn/concepts/list-of-operations.html" target="_blank">operation</a> to this transaction.
     * @param operation
     * @return Builder object so you can chain methods.
     * @see Operation
     */
    public Builder addOperation(Operation operation) {
      checkNotNull(operation, "operation cannot be null");
      mOperations.add(operation);
      return this;
    }

    /**
     * Adds a <a href="https://www.stellar.org/developers/learn/concepts/transactions.html" target="_blank">memo</a> to this transaction.
     * @param memo
     * @return Builder object so you can chain methods.
     * @see Memo
     */
    public Builder addMemo(Memo memo) {
      if (mMemo != null) {
        throw new RuntimeException("Memo has been already added.");
      }
      checkNotNull(memo, "memo cannot be null");
      mMemo = memo;
      return this;
    }
    
    /**
     * Adds a <a href="https://www.stellar.org/developers/learn/concepts/transactions.html" target="_blank">time-bounds</a> to this transaction.
     * @param timeBounds
     * @return Builder object so you can chain methods.
     * @see TimeBounds
     */
    public Builder addTimeBounds(TimeBounds timeBounds) {
      if (mTimeBounds != null) {
        throw new RuntimeException("TimeBounds has been already added.");
      }
      checkNotNull(timeBounds, "timeBounds cannot be null");
      mTimeBounds = timeBounds;
      return this;
    }

    /**
     * Because of the distributed nature of the Stellar network it is possible that the status of your transaction
     * will be determined after a long time if the network is highly congested.
     * If you want to be sure to receive the status of the transaction within a given period you should set the
     * {@link TimeBounds} with <code>maxTime</code> on the transaction (this is what <code>setTimeout</code> does
     * internally; if there's <code>minTime</code> set but no <code>maxTime</code> it will be added).
     * Call to <code>Builder.setTimeout</code> is required if Transaction does not have <code>max_time</code> set.
     * If you don't want to set timeout, use <code>TIMEOUT_INFINITE</code>. In general you should set
     * <code>TIMEOUT_INFINITE</code> only in smart contracts.
     * Please note that Horizon may still return <code>504 Gateway Timeout</code> error, even for short timeouts.
     * In such case you need to resubmit the same transaction again without making any changes to receive a status.
     * This method is using the machine system time (UTC), make sure it is set correctly.
     * @param timeout Timeout in seconds.
     * @see TimeBounds
     * @return
     */
    public Builder setTimeout(long timeout) {
      if (mTimeBounds != null && mTimeBounds.getMaxTime() > 0) {
        throw new RuntimeException("TimeBounds.max_time has been already set - setting timeout would overwrite it.");
      }

      if (timeout < 0) {
        throw new RuntimeException("timeout cannot be negative");
      }

      timeoutSet = true;
      if (timeout > 0) {
        long timeoutTimestamp = System.currentTimeMillis() / 1000L + timeout;
        if (mTimeBounds == null) {
          mTimeBounds = new TimeBounds(0, timeoutTimestamp);
        } else {
          mTimeBounds = new TimeBounds(mTimeBounds.getMinTime(), timeoutTimestamp);
        }
      }

      return this;
    }

    public Builder setBaseFee(int baseFee) {
      if (baseFee < MIN_BASE_FEE) {
        throw new IllegalArgumentException("baseFee cannot be smaller than the BASE_FEE (" + MIN_BASE_FEE + "): " + baseFee);
      }

      this.mBaseFee = baseFee;
      return this;
    }

    /**
     * Builds a transaction. It will increment sequence number of the source account.
     */
    public Transaction build() {
      // Ensure setTimeout called or maxTime is set
      if ((mTimeBounds == null || mTimeBounds != null && mTimeBounds.getMaxTime() == 0) && !timeoutSet) {
        throw new RuntimeException("TimeBounds has to be set or you must call setTimeout(TIMEOUT_INFINITE).");
      }

      if (mBaseFee == null) {
        throw new RuntimeException("mBaseFee has to be set. you must call setBaseFee().");
      }

      if (mNetwork == null) {
        throw new NoNetworkSelectedException();
      }

      Operation[] operations = new Operation[mOperations.size()];
      operations = mOperations.toArray(operations);
      Transaction transaction = new Transaction(
              mSourceAccount.getAccountId(),
              operations.length * mBaseFee,
              mSourceAccount.getIncrementedSequenceNumber(),
              operations,
              mMemo,
              mTimeBounds,
              mNetwork
      );
      // Increment sequence number when there were no exceptions when creating a transaction
      mSourceAccount.incrementSequenceNumber();
      return transaction;
    }
  }

  @Override
  public int hashCode() {
    return Objects.hashCode(
            this.mFee,
            this.mSourceAccount,
            this.mSequenceNumber,
            Arrays.hashCode(this.mOperations),
            this.mMemo,
            this.mTimeBounds,
            this.mSignatures,
            this.mNetwork
    );
  }

  @Override
  public boolean equals(Object object) {
    if (object == null || !(object instanceof Transaction)) {
      return false;
    }

    Transaction other = (Transaction) object;
    return Objects.equal(this.mFee, other.mFee) &&
            Objects.equal(this.mSourceAccount, other.mSourceAccount) &&
            Objects.equal(this.mSequenceNumber, other.mSequenceNumber) &&
            Arrays.equals(this.mOperations, other.mOperations) &&
            Objects.equal(this.mMemo, other.mMemo) &&
            Objects.equal(this.mTimeBounds, other.mTimeBounds) &&
            Objects.equal(this.mNetwork, other.mNetwork) &&
            Objects.equal(this.mSignatures, other.mSignatures);
  }
}