import type {
  Authorization,
  AuthorizationRequest,
  BlockTag,
  TransactionRequest,
} from "ethers";
import {
  assertArgument,
  computeAddress,
  ethers,
  getAddress,
  hexlify,
  resolveAddress,
  toUtf8Bytes,
  TransactionLike,
  TypedDataEncoder,
  Wallet,
} from "ethers";
import {
  HardhatNetworkAccountConfig,
  HardhatNetworkAccountsConfig,
  HttpNetworkAccountsConfig,
} from "hardhat/types/config";
import { derivePrivateKeys } from "hardhat/internal/core/providers/util";
import { HardhatEthersProvider } from "./internal/hardhat-ethers-provider";
import {
  copyRequest,
  getRpcTransaction,
  resolveProperties,
} from "./internal/ethers-utils";
import { HardhatEthersError, NotImplementedError } from "./internal/errors";
import { FUSAKA_TRANSACTION_GAS_LIMIT } from "./internal/constants";

export class HardhatEthersSigner implements ethers.Signer {
  private readonly _accounts:
    | HttpNetworkAccountsConfig
    | HardhatNetworkAccountsConfig;
  private _cachedPrivateKey: string | undefined;

  public readonly address: string;
  public readonly provider: ethers.JsonRpcProvider | HardhatEthersProvider;

  public static async create(provider: HardhatEthersProvider, address: string) {
    const hre = await import("hardhat");

    // depending on the config, we set a fixed gas limit for all transactions
    let gasLimit: number | undefined;

    if (hre.network.name === "hardhat") {
      // If we are connected to the in-process hardhat network and the config
      // has a fixed number as the gas config, we use that.
      // Hardhat core already sets this value to the block gas limit when the
      // user doesn't specify a number.
      if (hre.network.config.gas !== "auto") {
        gasLimit = hre.network.config.gas;
      }
    } else if (hre.network.name === "localhost") {
      const configuredGasLimit = hre.config.networks.localhost.gas;

      if (configuredGasLimit !== "auto") {
        // if the resolved gas config is a number, we use that
        gasLimit = configuredGasLimit;
      } else {
        // if the resolved gas config is "auto", we need to check that
        // the user config is undefined, because that's the default value;
        // otherwise explicitly setting the gas to "auto" would have no effect
        if (hre.userConfig.networks?.localhost?.gas === undefined) {
          // finally, we check if we are connected to a hardhat network
          let isHardhatNetwork = false;
          try {
            await hre.network.provider.send("hardhat_metadata");
            isHardhatNetwork = true;
          } catch {}

          if (isHardhatNetwork) {
            // WARNING: this assumes that the hardhat node is being run in the
            // same project which might be wrong
            gasLimit = Math.min(
              FUSAKA_TRANSACTION_GAS_LIMIT,
              hre.config.networks.hardhat.blockGasLimit
            );
          }
        }
      }
    }

    return new HardhatEthersSigner(
      address,
      provider,
      hre.network.config.accounts,
      gasLimit
    );
  }

  private constructor(
    address: string,
    _provider: ethers.JsonRpcProvider | HardhatEthersProvider,
    accounts: HttpNetworkAccountsConfig | HardhatNetworkAccountsConfig,
    private readonly _gasLimit?: number
  ) {
    this.address = getAddress(address);
    this.provider = _provider;
    this._accounts = accounts;
  }

  public connect(
    provider: ethers.JsonRpcProvider | HardhatEthersProvider
  ): ethers.Signer {
    return new HardhatEthersSigner(this.address, provider, this._accounts);
  }

  public async authorize(auth: AuthorizationRequest): Promise<Authorization> {
    const privateKey = this._getPrivateKey();

    if (privateKey === undefined) {
      throw new HardhatEthersError(
        `No private key found for address ${this.address}`
      );
    }

    const wallet = new Wallet(privateKey, this.provider);

    return wallet.authorize(auth);
  }

  public async populateAuthorization(
    _auth: AuthorizationRequest
  ): Promise<AuthorizationRequest> {
    const auth = { ..._auth };

    // Add a chain ID if not explicitly set to 0
    if (auth.chainId === null || auth.chainId === undefined) {
      auth.chainId = (await this.provider.getNetwork()).chainId;
    }

    if (auth.nonce === null || auth.nonce === undefined) {
      auth.nonce = await this.getNonce();
    }

    return auth;
  }

  public getNonce(blockTag?: BlockTag | undefined): Promise<number> {
    return this.provider.getTransactionCount(this.address, blockTag);
  }

  public populateCall(
    tx: TransactionRequest
  ): Promise<ethers.TransactionLike<string>> {
    return populate(this, tx);
  }

  public populateTransaction(
    tx: TransactionRequest
  ): Promise<ethers.TransactionLike<string>> {
    return this.populateCall(tx);
  }

  public async estimateGas(tx: TransactionRequest): Promise<bigint> {
    return this.provider.estimateGas(await this.populateCall(tx));
  }

  public async call(tx: TransactionRequest): Promise<string> {
    return this.provider.call(await this.populateCall(tx));
  }

  public resolveName(name: string): Promise<string | null> {
    return this.provider.resolveName(name);
  }

  public async signTransaction(_tx: TransactionRequest): Promise<string> {
    // TODO if we split the signer for the in-process and json-rpc networks,
    // we can enable this method when using the in-process network or when the
    // json-rpc network has a private key
    throw new NotImplementedError("HardhatEthersSigner.signTransaction");
  }

  public async sendTransaction(
    tx: TransactionRequest
  ): Promise<ethers.TransactionResponse> {
    // This cannot be mined any earlier than any recent block
    const blockNumber = await this.provider.getBlockNumber();

    // Send the transaction
    const hash = await this._sendUncheckedTransaction(tx);

    // Unfortunately, JSON-RPC only provides and opaque transaction hash
    // for a response, and we need the actual transaction, so we poll
    // for it; it should show up very quickly

    return new Promise((resolve) => {
      const timeouts = [1000, 100];
      const checkTx = async () => {
        // Try getting the transaction
        const txPolled = await this.provider.getTransaction(hash);
        if (txPolled !== null) {
          resolve(txPolled.replaceableTransaction(blockNumber));
          return;
        }

        // Wait another 4 seconds
        setTimeout(() => {
          // eslint-disable-next-line @typescript-eslint/no-floating-promises
          checkTx();
        }, timeouts.pop() ?? 4000);
      };
      // eslint-disable-next-line @typescript-eslint/no-floating-promises
      checkTx();
    });
  }

  public signMessage(message: string | Uint8Array): Promise<string> {
    const resolvedMessage =
      typeof message === "string" ? toUtf8Bytes(message) : message;
    return this.provider.send("personal_sign", [
      hexlify(resolvedMessage),
      this.address.toLowerCase(),
    ]);
  }

  public async signTypedData(
    domain: ethers.TypedDataDomain,
    types: Record<string, ethers.TypedDataField[]>,
    value: Record<string, any>
  ): Promise<string> {
    const copiedValue = deepCopy(value);

    // Populate any ENS names (in-place)
    const populated = await TypedDataEncoder.resolveNames(
      domain,
      types,
      copiedValue,
      async (v: string) => {
        return v;
      }
    );

    return this.provider.send("eth_signTypedData_v4", [
      this.address.toLowerCase(),
      JSON.stringify(
        TypedDataEncoder.getPayload(populated.domain, types, populated.value),
        (_k, v) => {
          if (typeof v === "bigint") {
            return v.toString();
          }

          return v;
        }
      ),
    ]);
  }

  public async getAddress(): Promise<string> {
    return this.address;
  }

  public toJSON() {
    return `<SignerWithAddress ${this.address}>`;
  }

  private _getPrivateKey(): string | undefined {
    if (this._cachedPrivateKey === undefined) {
      const privateKeys = this._getPrivateKeys();
      const privateKey = privateKeys.find(
        (key) => computeAddress(key) === this.address
      );

      this._cachedPrivateKey = privateKey;
    }

    return this._cachedPrivateKey;
  }

  private _getPrivateKeys(): string[] {
    if (this._accounts === "remote") {
      throw new HardhatEthersError(
        `Tried to obtain a private key, but the network is configured to use remote accounts`
      );
    }

    if (Array.isArray(this._accounts)) {
      if (typeof this._accounts[0] === "string") {
        return this._accounts as string[];
      }

      return (this._accounts as HardhatNetworkAccountConfig[]).map(
        (acc) => acc.privateKey
      );
    }

    if ("mnemonic" in this._accounts) {
      return derivePrivateKeys(
        this._accounts.mnemonic,
        this._accounts.path,
        this._accounts.initialIndex,
        this._accounts.count,
        this._accounts.passphrase
      ).map((pk) => `0x${pk.toString("hex")}`);
    }

    // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
    throw new HardhatEthersError("Assertion error: unsupported accounts type");
  }

  private async _sendUncheckedTransaction(
    tx: TransactionRequest
  ): Promise<string> {
    const resolvedTx = deepCopy(tx);

    const promises: Array<Promise<void>> = [];

    // Make sure the from matches the sender
    if (resolvedTx.from !== null && resolvedTx.from !== undefined) {
      const _from = resolvedTx.from;
      promises.push(
        (async () => {
          const from = await resolveAddress(_from, this.provider);
          assertArgument(
            from !== null &&
              from !== undefined &&
              from.toLowerCase() === this.address.toLowerCase(),
            "from address mismatch",
            "transaction",
            tx
          );
          resolvedTx.from = from;
        })()
      );
    } else {
      resolvedTx.from = this.address;
    }

    if (resolvedTx.gasLimit === null || resolvedTx.gasLimit === undefined) {
      if (this._gasLimit !== undefined) {
        resolvedTx.gasLimit = this._gasLimit;
      } else {
        promises.push(
          (async () => {
            resolvedTx.gasLimit = await this.provider.estimateGas({
              ...resolvedTx,
              from: this.address,
            });
          })()
        );
      }
    }

    // The address may be an ENS name or Addressable
    if (resolvedTx.to !== null && resolvedTx.to !== undefined) {
      const _to = resolvedTx.to;
      promises.push(
        (async () => {
          resolvedTx.to = await resolveAddress(_to, this.provider);
        })()
      );
    }

    // Wait until all of our properties are filled in
    if (promises.length > 0) {
      await Promise.all(promises);
    }

    const hexTx = getRpcTransaction(resolvedTx);

    return this.provider.send("eth_sendTransaction", [hexTx]);
  }
}

// exported as an alias to make migration easier
export { HardhatEthersSigner as SignerWithAddress };

async function populate(
  signer: ethers.Signer,
  tx: TransactionRequest
): Promise<TransactionLike<string>> {
  const pop: any = copyRequest(tx);

  if (pop.to !== null && pop.to !== undefined) {
    pop.to = resolveAddress(pop.to, signer);
  }

  if (pop.from !== null && pop.from !== undefined) {
    const from = pop.from;
    pop.from = Promise.all([
      signer.getAddress(),
      resolveAddress(from, signer),
    ]).then(([address, resolvedFrom]) => {
      assertArgument(
        address.toLowerCase() === resolvedFrom.toLowerCase(),
        "transaction from mismatch",
        "tx.from",
        resolvedFrom
      );
      return address;
    });
  } else {
    pop.from = signer.getAddress();
  }

  return resolveProperties(pop);
}

const Primitive = "bigint,boolean,function,number,string,symbol".split(/,/g);
function deepCopy<T = any>(value: T): T {
  if (
    value === null ||
    value === undefined ||
    Primitive.indexOf(typeof value) >= 0
  ) {
    return value;
  }

  // Keep any Addressable
  if (typeof (value as any).getAddress === "function") {
    return value;
  }

  if (Array.isArray(value)) {
    return (value as any).map(deepCopy);
  }

  if (typeof value === "object") {
    return Object.keys(value).reduce((accum, key) => {
      accum[key] = (value as any)[key];
      return accum;
    }, {} as any);
  }

  throw new HardhatEthersError(
    `Assertion error: ${value as any} (${typeof value})`
  );
}
