import path from "path";
import _ from "lodash";
import {parse, visit} from "@solidity-parser/parser";
import { Interface } from "@ethersproject/abi";

import { EthereumProvider, HardhatRuntimeEnvironment } from "hardhat/types";
import { getHashedFunctionSignature } from "../utils/sources";

import { RemoteContract, ContractInfo, GasReporterOptions } from "../types";

/**
 * Filters out contracts to exclude from report
 * @param  {string}   qualifiedName        HRE artifact identifier
 * @param  {string[]} skippable            excludeContracts option values
 * @return {boolean}
 */
function shouldSkipContract(
  qualifiedName: string,
  skippable: string[]
): boolean {
  for (const item of skippable) {
    if (qualifiedName.includes(item)) {
      return true;
    }
  }
  return false;
}

/**
 * Fetches remote bytecode at address and hashes it so these addresses can be
 * added to the tracking in the collector
 * @param  {EGRAsyncApiProvider}   provider
 * @param  {RemoteContract[] = []} remoteContracts
 * @return {Promise<RemoteContract[]>}
 */
export async function getResolvedRemoteContracts(
  provider: EthereumProvider,
  remoteContracts: RemoteContract[] = []
): Promise<RemoteContract[]> {
  const { default: sha1 } = await import("sha1");
  for (const contract of remoteContracts) {
    try {
      contract.bytecode = await provider.send("eth_getCode", [contract.address, "latest"]);
      contract.deployedBytecode = contract.bytecode;
      contract.bytecodeHash = sha1(contract.bytecode!);
    } catch (error: any) {
      console.log(
        `hardhat-gas-reporter:warning: failed to fetch bytecode for remote contract: ${contract.name}`
      );
      console.log(`Error was: ${error}\n`);
    }
  }
  return remoteContracts;
}

/**
 * Loads and processes artifacts
 * @param  {HardhatRuntimeEnvironment} hre
 * @param  {GasReporterOptions[]}      options
 * @return {ContractInfo[]}
 */
export async function getContracts(
  hre: HardhatRuntimeEnvironment,
  options: GasReporterOptions,
): Promise<ContractInfo[]> {
  const visited = {};
  const contracts: ContractInfo[] = [];

  const resolvedRemoteContracts = await getResolvedRemoteContracts(
    hre.network.provider,
    options.remoteContracts
  );

  const resolvedQualifiedNames = await hre.artifacts.getAllFullyQualifiedNames();

  for (const qualifiedName of resolvedQualifiedNames) {
    if (shouldSkipContract(qualifiedName, options.excludeContracts!)) {
      continue;
    }

    let name: string;
    let artifact = await hre.artifacts.readArtifact(qualifiedName);

    // Prefer simple names
    try {
      artifact = await hre.artifacts.readArtifact(artifact.contractName);
      name = artifact.contractName;
    } catch (e) {
      name = path.relative(hre.config.paths.sources, qualifiedName);;
    }

    const excludedMethods = await getExcludedMethodKeys(
      hre,
      options,
      artifact.abi,
      name,
      qualifiedName,
      visited
    );

    contracts.push({
      name,
      excludedMethods,
      artifact: {
        abi: artifact.abi,
        bytecode: artifact.bytecode,
        deployedBytecode: artifact.deployedBytecode,
      },
    });
  }

  for (const remoteContract of resolvedRemoteContracts) {
    contracts.push({
      name: remoteContract.name,
      excludedMethods: [], // no source
      artifact: {
        abi: remoteContract.abi,
        address: remoteContract.address,
        bytecode: remoteContract.bytecode,
        bytecodeHash: remoteContract.bytecodeHash,
        deployedBytecode: remoteContract.deployedBytecode,
      },
    } as ContractInfo);
  }
  return contracts;
}

/**
 * Parses each file in a contract's dependency tree to identify public StateVariables and
 * add them to a list of methods to exclude from the report. Enabled when
 * `excludeAutoGeneratedGetters` and `reportPureAndViewMethods` are both true.
 *
 * TODO: warn when files don't parse
 *
 * @param {HardhatRuntimeEnvironment} hre
 * @param {GasReporterOptions}        options
 * @param {any[]}                     abi
 * @param {string}                    name
 * @param {string}                    qualifiedName
 * @param {[key: string]: string[]}   visited (cache)
 * @returns {Promise<string[]>}
 */
async function getExcludedMethodKeys(
  hre: HardhatRuntimeEnvironment,
  options: GasReporterOptions,
  abi: any[],
  contractName: string,
  contractQualifiedName: string,
  visited: {[key: string]: string[]}
): Promise<string[]> {
  const excludedMethods = new Set();

  if (options.reportPureAndViewMethods && options.excludeAutoGeneratedGetters) {
    const info = await hre.artifacts.getBuildInfo(contractQualifiedName);
    const functions = new Interface(abi).functions

    if (info && info.input && info.input.sources) {
      _.forEach(info?.input.sources, (source, sourceKey) => {
        // Cache dependency sources
        if (!visited[sourceKey]){
          visited[sourceKey] = [];
        } else {
          visited[sourceKey].forEach(_name => {
            if (!excludedMethods.has(_name)){
              excludedMethods.add(`${contractName}_${getHashedFunctionSignature(_name)}`)
            }
          })
          return;
        };

        try {
          const ast = parse(source.content, {tolerant: true});
          visit(ast, {
            StateVariableDeclaration (node) {
              const publicVars = node.variables.filter(({ visibility }) => visibility === 'public');
              publicVars.forEach(_var => {
                const formattedName = Object.keys(functions).find(key => functions[key].name === _var.name);
                if (formattedName){
                  visited[sourceKey].push(formattedName);
                  excludedMethods.add(`${contractName}_${getHashedFunctionSignature(formattedName)}`)
                }
              })
            }
          })
        } catch (err) { /* ignore */ }
      });
    }
  }
  return Array.from(excludedMethods) as string[];
}
