import pino from "pino";
import { TaskQueue, timeoutHelper, workflows } from "mvc-common-toolkit";
import { BigNumber, Signer } from "ethers";

import {
  TransactionHistoryStorage,
  IWeb3Gateway,
  SignerPicker,
} from "../common/interfaces";
import { ContractModel } from "../models";
import { AdditionalGas, MinGas, TransactionStatus } from "../common/constants";
import { RoundRobinSignerPicker } from "../services";
import { getOptimizedGasPriceV2 } from "../utils";

export class BlockchainTransactionRegistry {
  protected _providerMap: Map<string, IWeb3Gateway>;
  private logger = pino();
  protected signerPicker: SignerPicker;

  constructor(
    protected transactionStorage: TransactionHistoryStorage,
    signerPicker: SignerPicker,
    protected taskQueue: TaskQueue
  ) {
    this.signerPicker =
      signerPicker || new RoundRobinSignerPicker(transactionStorage);
  }

  /**
   * This function based on some assumptions:
   * - The user of this class already implemented concurrency control
   * - The providers are only used programmatically, with no ad-hoc transactions
   * @param contract
   * @param method
   * @param params
   * @param options
   */
  public async sendContractTransaction(
    contract: ContractModel,
    method: string,
    params: any[],
    options?: {
      signerPicker?: SignerPicker;
      timeoutInMs?: number;
      minGas?: string;
    }
  ) {
    const signerPicker = options?.signerPicker || this.signerPicker;
    const pickedSigner = await signerPicker.pick(contract.address, []);

    const signerAddress = await pickedSigner.getAddress();

    const queueName = `signer_queue_${signerAddress.toLowerCase()}`;
    const taskName = `exec_${method}`;

    // This makes sure parallelism for all signers not having to queue up
    return this.taskQueue.push(queueName, taskName, async () => {
      let generatedTxHash: string;

      try {
        const { isOverride, nextNonce } = await this.reconcileSignerLastTx(
          pickedSigner,
          signerAddress
        );

        const gasPrice = await getOptimizedGasPriceV2(
          pickedSigner.provider,
          AdditionalGas.toString(),
          options?.minGas || MinGas.toString(),
          {
            useOverridingGas: isOverride,
          }
        );

        const { signedTransaction, preCalculatedHash: txHash } =
          await contract.generateTransaction(
            {
              data: params,
              functionName: method,
              nonce: nextNonce,
            },
            {
              gasPrice,
            }
          );

        await this.transactionStorage.create({
          nonce: nextNonce,
          txHash,
          signedTransaction,
          signerAddress,
          status: TransactionStatus.SCHEDULED,
          metadata: {
            isOverride,
          },
        });

        generatedTxHash = txHash;

        if (!contract.provider.sendTransaction) {
          throw new Error("provider has no sendTransaction method");
        }

        await timeoutHelper.runWithTimeout(async () => {
          const submittedTx = await pickedSigner.provider.sendTransaction(
            signedTransaction
          );

          await submittedTx.wait(1);

          await this.transactionStorage.updateByTxHash(txHash, {
            status: "executed",
          });
        }, options?.timeoutInMs || 30000);
      } catch (error) {
        this.logger.error(error.message, error.stack);

        if (generatedTxHash) {
          await this.transactionStorage.updateByTxHash(generatedTxHash, {
            status: TransactionStatus.FAILED,
            metadata: error,
          });
        }
      }
    });
  }

  /**
   * Check if last tx was actually failed, if so then update its status to EXECUTED.
   * Edge case: The overriding tx failed, but the original timeout tx actually succeeded.
   * In this scenario,
   * @param signer The picked signer
   * @param signerAddress The signer address
   */
  protected async reconcileSignerLastTx(
    signer: Signer,
    signerAddress: string
  ): Promise<{ nextNonce: string; isOverride: boolean }> {
    // Notice: This function should sort transactions nonce DESC and status ASC (so EXECUTED comes before FAILED and SCHEDULED)
    const lastTx = await this.transactionStorage.findSignerLastTransaction(
      signerAddress
    );

    if (!lastTx) {
      const count = await signer.getTransactionCount();

      return {
        nextNonce: String(count),
        isOverride: false,
      };
    }

    const { txHash, status, nonce } = lastTx;

    if (status === TransactionStatus.SCHEDULED) {
      throw new Error(
        `txHash ${txHash} for signer ${signerAddress} is still pending`
      );
    }

    if (status === TransactionStatus.FAILED) {
      const foundTx = await signer.provider.getTransaction(txHash);
      /**
       * Last tx was not found, meaning it hasn't been accepted. This doesn't mean the transaction
       * will not be picked, but we can TRY to override it.
       */
      if (!foundTx) {
        return {
          nextNonce: nonce,
          isOverride: true,
        };
      }
    }

    const nextNonce = BigNumber.from(nonce).add(1).toString();

    return {
      nextNonce,
      isOverride: false,
    };
  }
}
