//
// Copyright 2021 Vulcanize, Inc.
//

import assert from 'assert';
import { errors, providers, utils } from 'ethers';

import { Cache } from '@cerc-io/cache';
import { encodeHeader, escapeHexString, EthClient as EthClientInterface, EthFullBlock, EthFullTransaction } from '@cerc-io/util';
import { padKey } from '@cerc-io/ipld-eth-client';

const FUTURE_BLOCK_ERROR = "requested a future epoch (beyond 'latest')";
const NULL_BLOCK_ERROR = 'requested epoch was a null round';

export interface Config {
  cache: Cache | undefined;
  rpcEndpoint: string;
}

interface Vars {
  blockHash?: string;
  blockNumber?: string;
  contract?: string;
  slot?: string;
  addresses?: string[];
  topics?: string[][];
  fromBlock?: number;
  toBlock?: number;
}

export class EthClient implements EthClientInterface {
  _provider: providers.JsonRpcProvider;
  _cache: Cache | undefined;

  constructor (config: Config) {
    const { rpcEndpoint, cache } = config;
    assert(rpcEndpoint, 'Missing RPC endpoint');
    this._provider = new providers.StaticJsonRpcProvider({
      url: rpcEndpoint,
      allowGzip: true
    });

    this._cache = cache;
  }

  async getStorageAt ({ blockHash, contract, slot }: { blockHash: string, contract: string, slot: string }): Promise<{ value: string, proof: { data: string } }> {
    slot = `0x${padKey(slot)}`;

    console.time(`time:eth-client#getStorageAt-${JSON.stringify({ blockHash, contract, slot })}`);
    const value = await this._getCachedOrFetch(
      'getStorageAt',
      { blockHash, contract, slot },
      async () => {
        // TODO: Check if blockHash works with Lotus RPC
        return this._provider.getStorageAt(contract, slot, blockHash);
      }
    );
    console.timeEnd(`time:eth-client#getStorageAt-${JSON.stringify({ blockHash, contract, slot })}`);

    return {
      value,
      proof: {
        // TODO: Return proof with cid and ipldBlock
        // To match getStorageAt method of ipld-eth-client which returns proof along with value.
        data: JSON.stringify(null)
      }
    };
  }

  async getBlockWithTransactions ({ blockNumber, blockHash }: { blockNumber?: number, blockHash?: string }): Promise<any> {
    const blockHashOrBlockNumber = blockHash ?? blockNumber;
    assert(blockHashOrBlockNumber);
    console.time(`time:eth-client#getBlockWithTransactions-${JSON.stringify({ blockNumber, blockHash })}`);
    let nodes: any[] = [];

    try {
      const result = await this._provider.getBlockWithTransactions(blockHashOrBlockNumber);

      nodes = [
        {
          blockNumber: result.number.toString(),
          blockHash: result.hash,
          parentHash: result.parentHash,
          timestamp: result.timestamp.toString(),
          ethTransactionCidsByHeaderId: {
            nodes: result.transactions.map((transaction) => ({
              txHash: transaction.hash,
              // Transactions with block should be of type TransactionReceipt
              index: (transaction as unknown as providers.TransactionReceipt).transactionIndex,
              src: transaction.from,
              dst: transaction.to
            }))
          }
        }
      ];
    } catch (err: any) {
      return this._handleGetBlockErrors(err);
    } finally {
      console.timeEnd(`time:eth-client#getBlockWithTransactions-${JSON.stringify({ blockNumber, blockHash })}`);
    }

    return {
      allEthHeaderCids: {
        nodes
      }
    };
  }

  async getBlocks ({ blockNumber, blockHash }: { blockNumber?: number, blockHash?: string }): Promise<any> {
    const blockNumberHex = blockNumber ? utils.hexValue(blockNumber) : undefined;
    const blockHashOrBlockNumber = blockHash ?? blockNumberHex;
    assert(blockHashOrBlockNumber);
    let nodes: any[] = [];
    console.time(`time:eth-client#getBlocks-${JSON.stringify({ blockNumber, blockHash })}`);

    try {
      const rawBlock = await this._provider.send(
        blockHash ? 'eth_getBlockByHash' : 'eth_getBlockByNumber',
        [blockHashOrBlockNumber, false]
      );

      if (rawBlock) {
        const block = this._provider.formatter.block(rawBlock);

        nodes = [
          {
            blockNumber: block.number.toString(),
            blockHash: block.hash,
            parentHash: block.parentHash,
            timestamp: block.timestamp.toString(),
            stateRoot: this._provider.formatter.hash(rawBlock.stateRoot),
            td: this._provider.formatter.bigNumber(rawBlock.totalDifficulty).toString(),
            txRoot: this._provider.formatter.hash(rawBlock.transactionsRoot),
            receiptRoot: this._provider.formatter.hash(rawBlock.receiptsRoot)
          }
        ];
      }
    } catch (err: any) {
      return this._handleGetBlockErrors(err);
    } finally {
      console.timeEnd(`time:eth-client#getBlocks-${JSON.stringify({ blockNumber, blockHash })}`);
    }

    return {
      allEthHeaderCids: {
        nodes
      }
    };
  }

  async getFullBlocks ({ blockNumber, blockHash }: { blockNumber?: number, blockHash?: string }): Promise<Array<EthFullBlock | null>> {
    const blockNumberHex = blockNumber ? utils.hexValue(blockNumber) : undefined;
    const blockHashOrBlockNumber = blockHash ?? blockNumberHex;
    assert(blockHashOrBlockNumber);
    console.time(`time:eth-client#getFullBlocks-${JSON.stringify({ blockNumber, blockHash })}`);

    try {
      const rawBlock = await this._provider.send(
        blockHash ? 'eth_getBlockByHash' : 'eth_getBlockByNumber',
        [blockHashOrBlockNumber, false]
      );

      if (rawBlock) {
        // Create block header
        // https://github.com/cerc-io/go-ethereum/blob/v1.11.6-statediff-5.0.8/core/types/block.go#L64
        const header = {
          Parent: rawBlock.parentHash,
          UnclesDigest: rawBlock.sha3Uncles,
          Beneficiary: rawBlock.miner,
          StateRoot: rawBlock.stateRoot,
          TxRoot: rawBlock.transactionsRoot,
          RctRoot: rawBlock.receiptsRoot,
          Bloom: rawBlock.logsBloom,
          Difficulty: BigInt(rawBlock.difficulty),
          Number: BigInt(rawBlock.number),
          GasLimit: BigInt(rawBlock.gasLimit),
          GasUsed: BigInt(rawBlock.gasUsed),
          Time: Number(rawBlock.timestamp),
          Extra: rawBlock.extraData,
          MixDigest: rawBlock.mixHash,
          Nonce: BigInt(rawBlock.nonce),
          BaseFee: rawBlock.baseFeePerGas && BigInt(rawBlock.baseFeePerGas)
        };

        const rlpData = encodeHeader(header);

        return [{
          blockNumber: this._provider.formatter.number(rawBlock.number).toString(),
          blockHash: this._provider.formatter.hash(rawBlock.hash),
          parentHash: this._provider.formatter.hash(rawBlock.parentHash),
          timestamp: this._provider.formatter.number(rawBlock.timestamp).toString(),
          stateRoot: this._provider.formatter.hash(rawBlock.stateRoot),
          td: this._provider.formatter.bigNumber(rawBlock.totalDifficulty).toString(),
          txRoot: this._provider.formatter.hash(rawBlock.transactionsRoot),
          receiptRoot: this._provider.formatter.hash(rawBlock.receiptsRoot),
          uncleRoot: this._provider.formatter.hash(rawBlock.sha3Uncles),
          bloom: escapeHexString(this._provider.formatter.hex(rawBlock.logsBloom)),
          size: this._provider.formatter.number(rawBlock.size).toString(),
          blockByMhKey: {
            data: escapeHexString(rlpData)
          }
        }];
      }
    } catch (err: any) {
      return this._handleGetBlockErrors(err);
    } finally {
      console.timeEnd(`time:eth-client#getFullBlocks-${JSON.stringify({ blockNumber, blockHash })}`);
    }

    return [];
  }

  async getFullTransaction (txHash: string): Promise<EthFullTransaction> {
    console.time(`time:eth-client#getFullTransaction-${JSON.stringify({ txHash })}`);
    const tx = await this._provider.getTransaction(txHash);
    console.timeEnd(`time:eth-client#getFullTransaction-${JSON.stringify({ txHash })}`);

    return {
      ethTransactionCidByTxHash: {
        txHash: tx.hash,
        index: (tx as any).transactionIndex,
        src: tx.from,
        dst: tx.to
      },
      data: tx
    };
  }

  async getBlockByHash (blockHash?: string): Promise<any> {
    const blockTag: providers.BlockTag = blockHash ?? 'latest';

    console.time(`time:eth-client#getBlockByHash-${blockHash}`);
    const block = await this._provider.getBlock(blockTag);
    console.timeEnd(`time:eth-client#getBlockByHash-${blockHash}`);

    return {
      block: {
        number: block.number,
        hash: block.hash,
        parent: {
          hash: block.parentHash
        },
        timestamp: block.timestamp
      }
    };
  }

  async getLogs (vars: {
    blockHash: string,
    blockNumber: string,
    addresses?: string[],
    topics?: string[][]
  }): Promise<any> {
    console.time(`time:eth-client#getLogs-${JSON.stringify(vars)}`);
    const result = await this._getLogs({
      blockHash: vars.blockHash,
      addresses: vars.addresses,
      topics: vars.topics
    });
    console.timeEnd(`time:eth-client#getLogs-${JSON.stringify(vars)}`);

    return result;
  }

  async getLogsForBlockRange (vars: {
    fromBlock?: number,
    toBlock?: number,
    addresses?: string[],
    topics?: string[][]
  }): Promise<any> {
    console.time(`time:eth-client#getLogsForBlockRange-${JSON.stringify(vars)}`);
    const result = await this._getLogs({
      fromBlock: Number(vars.fromBlock),
      toBlock: Number(vars.toBlock),
      addresses: vars.addresses,
      topics: vars.topics
    });
    console.timeEnd(`time:eth-client#getLogsForBlockRange-${JSON.stringify(vars)}`);

    return result;
  }

  // TODO: Implement return type
  async _getLogs (vars: {
    blockHash?: string,
    fromBlock?: number,
    toBlock?: number,
    addresses?: string[],
    topics?: string[][]
  }): Promise<any> {
    const { blockHash, fromBlock, toBlock, addresses = [], topics } = vars;

    const result = await this._getCachedOrFetch(
      'getLogs',
      vars,
      async () => {
        const ethLogs = await this._provider.send(
          'eth_getLogs',
          [{
            address: addresses.map(address => address.toLowerCase()),
            fromBlock: fromBlock && utils.hexValue(fromBlock),
            toBlock: toBlock && utils.hexValue(toBlock),
            blockHash,
            topics
          }]
        );

        // Format raw eth_getLogs response
        const logs: providers.Log[] = providers.Formatter.arrayOf(
          this._provider.formatter.filterLog.bind(this._provider.formatter)
        )(ethLogs);

        return logs.map((log) => {
          log.address = log.address.toLowerCase();
          return log;
        });
      }
    );

    const txHashesSet = result.reduce((acc, log) => {
      acc.add(log.transactionHash);
      return acc;
    }, new Set<string>());

    const txReceipts = await Promise.all(Array.from(txHashesSet).map(txHash => this._provider.getTransactionReceipt(txHash)));

    const txReceiptMap = txReceipts.reduce((acc, txReceipt) => {
      acc.set(txReceipt.transactionHash, txReceipt);
      return acc;
    }, new Map<string, providers.TransactionReceipt>());

    return {
      logs: result.map((log) => ({
        // blockHash required for sorting logs fetched in a block range
        blockHash: log.blockHash,
        account: {
          address: log.address
        },
        transaction: {
          hash: log.transactionHash
        },
        topics: log.topics,
        data: log.data,
        index: log.logIndex,
        status: txReceiptMap.get(log.transactionHash)?.status
      }))
    };
  }

  async _getCachedOrFetch<Result> (queryName: string, vars: Vars, fetch: () => Promise<Result>): Promise<Result> {
    const keyObj = {
      queryName,
      vars
    };

    // Check if request cached in db, if cache is enabled.
    if (this._cache) {
      const [value, found] = await this._cache.get(keyObj) || [undefined, false];
      if (found) {
        return value;
      }
    }

    // Result not cached or cache disabled, need to perform fetch.
    const result = await fetch();

    // Cache the result and return it, if cache is enabled.
    if (this._cache) {
      await this._cache.put(keyObj, result);
    }

    return result;
  }

  _handleGetBlockErrors (err: any): Array<null> {
    if (err.code === errors.SERVER_ERROR && err.error) {
      // Check null block error and return null array
      if (err.error.message === NULL_BLOCK_ERROR) {
        return [null];
      }

      // Check and ignore future block error
      if (err.error.message === FUTURE_BLOCK_ERROR) {
        return [];
      }
    }

    throw err;
  }
}
