import { BaseProvider } from '@ethersproject/providers';

import {
  Call,
  all as callAll,
  tryAll as callTryAll,
  tryEach as callTryEach,
} from './call';
import getEthBalance from './calls';
import {
  Multicall,
  getMulticall,
  getMulticall2,
  getMulticall3,
} from './multicall';

const DEFAULT_CHAIN_ID = 1;

type CallType = 'BASIC' | 'TRY_ALL' | 'TRY_EACH';

type BlockTag = number | 'latest' | 'pending';

/**
 * Represents a Multicall provider. Used to execute multiple Calls.
 */
class Provider {
  provider?: BaseProvider;
  multicall: Multicall | null;
  multicall2: Multicall | null;
  multicall3: Multicall | null;

  /**
   * Create a provider.
   */
  constructor() {
    this.multicall = getMulticall(DEFAULT_CHAIN_ID);
    this.multicall2 = getMulticall2(DEFAULT_CHAIN_ID);
    this.multicall3 = getMulticall3(DEFAULT_CHAIN_ID);
  }

  /**
   * Initialize the provider. Should be called once before making any requests.
   * @param provider ethers provider
   */
  async init(provider: BaseProvider): Promise<void> {
    this.provider = provider;
    const network = await provider.getNetwork();
    this.multicall = getMulticall(network.chainId);
    this.multicall2 = getMulticall2(network.chainId);
    this.multicall3 = getMulticall3(network.chainId);
  }

  /**
   * Make one call to the multicall contract to retrieve eth balance of the given address.
   * @param address Address of the account you want to look up
   * @returns Ether balance fetching call
   */
  getEthBalance(address: string): Call {
    const multicall = this.multicall || this.multicall2 || this.multicall3;
    if (!multicall) {
      throw Error('Multicall contract is not available on this network.');
    }
    return getEthBalance(address, multicall.address);
  }

  /**
   * Aggregate multiple calls into one call.
   * Reverts when any of the calls fails.
   * For ignoring the success of each call, use {@link tryAll} instead.
   * @param calls Array of Call objects containing information about each read call
   * @param block Block number for this call
   * @returns List of fetched data
   */
  async all<T>(calls: Call[], block?: BlockTag): Promise<T[]> {
    if (!this.provider) {
      throw Error('Provider should be initialized before use.');
    }
    const multicall = this.getContract('BASIC', block);
    if (!multicall) {
      console.warn(
        'Multicall contract is not available on this network, using deployless version.',
      );
    }
    const provider = this.provider as BaseProvider;
    return await callAll<T>(provider, multicall, calls, block);
  }

  /**
   * Aggregate multiple calls into one call.
   * If any of the calls fail, it returns a null value in place of the failed call's return data.
   * @param calls Array of Call objects containing information about each read call
   * @param block Block number for this call
   * @returns List of fetched data. Failed calls will result in null values.
   */
  async tryAll<T>(calls: Call[], block?: number): Promise<(T | null)[]> {
    if (!this.provider) {
      throw Error('Provider should be initialized before use.');
    }
    const multicall = this.getContract('TRY_ALL', block);
    if (!multicall) {
      console.warn(
        'Multicall2 contract is not available on this network, using deployless version.',
      );
    }
    const provider = this.provider as BaseProvider;
    return await callTryAll<T>(provider, multicall, calls, block);
  }

  /**
   * Aggregates multiple calls into one call.
   * If any of the calls that are allowed to fail do fail,
   * it returns a null value in place of the failed call's return data.
   * @param calls Array of Call objects containing information about each read call
   * @param canFail Array of booleans specifying whether each call can fail
   * @param block Block number for this call
   * @returns List of fetched data. Failed calls will result in null values.
   */
  async tryEach<T>(
    calls: Call[],
    canFail: boolean[],
    block?: number,
  ): Promise<(T | null)[]> {
    if (!this.provider) {
      throw Error('Provider should be initialized before use.');
    }
    const multicall = this.getContract('TRY_EACH', block);
    if (!multicall) {
      console.warn(
        'Multicall3 contract is not available on this network, using deployless version.',
      );
    }
    const provider = this.provider as BaseProvider;
    const failableCalls = calls.map((call, index) => {
      return {
        ...call,
        canFail: canFail[index],
      };
    });
    return await callTryEach<T>(provider, multicall, failableCalls, block);
  }

  getContract(call: CallType, block?: BlockTag): Multicall | null {
    const multicall = this.isAvailable(this.multicall, block)
      ? this.multicall
      : null;
    const multicall2 = this.isAvailable(this.multicall2, block)
      ? this.multicall2
      : null;
    const multicall3 = this.isAvailable(this.multicall3, block)
      ? this.multicall3
      : null;
    switch (call) {
      case 'BASIC':
        return multicall3 || multicall2 || multicall;
      case 'TRY_ALL':
        return multicall3 || multicall2;
      case 'TRY_EACH':
        return multicall3;
    }
  }

  isAvailable(multicall: Multicall | null, block?: BlockTag): boolean {
    if (!multicall) {
      return false;
    }
    if (!block) {
      return true;
    }
    if (block === 'latest' || block === 'pending') {
      return true;
    }
    return multicall.block < block;
  }
}

export default Provider;

export type { BlockTag };