import type { SolidityBuildInfo, SolidityBuildInfoOutput } from "hardhat/types/solidity";

import pluginSolidity from "prettier-plugin-solidity";
import {
  ContractDefinition,
  EnumDefinition,
  ErrorDefinition,
  EventDefinition,
  FunctionDefinition,
  ModifierDefinition,
  ModifierInvocation,
  SourceLocation,
  SourceUnit,
  StructDefinition,
  VariableDeclaration,
} from "solidity-ast";
import type { Node } from "solidity-ast/node.js";
import { ASTDereferencer, astDereferencer, findAll, isNodeType } from "solidity-ast/utils.js";

import {
  CONSTANTS_BLOCK_NAME,
  DEFAULT_LICENSE,
  ENUMS_BLOCK_NAME,
  ERRORS_BLOCK_NAME,
  EVENTS_BLOCK_NAME,
  FUNCTIONS_BLOCK_NAME,
  MODIFIERS_BLOCK_NAME,
  STATE_VARIABLES_BLOCK_NAME,
  STRUCTS_BLOCK_NAME,
} from "./constants.js";
import { ContractInfo, DocumentationBlock, NatSpecDocumentation } from "./types.js";
import { removeHardhatNamespacePrefix } from "./utils.js";

import prettier = require("prettier");

export class Parser {
  private contractBuildInfo: SolidityBuildInfo;
  private contractBuildInfoOutput: SolidityBuildInfoOutput;
  private readonly deref: ASTDereferencer;

  constructor(contractBuildInfo: SolidityBuildInfo, contractBuildInfoOutput: SolidityBuildInfoOutput) {
    this.contractBuildInfo = contractBuildInfo;
    this.contractBuildInfoOutput = contractBuildInfoOutput;

    this.deref = astDereferencer(contractBuildInfoOutput.output);
  }

  async parseContractInfo(source: string, name: string): Promise<ContractInfo> {
    const absolutePath = Object.values(this.contractBuildInfoOutput.output.sources).find(
      (source_rs) => source == removeHardhatNamespacePrefix(source_rs.ast.absolutePath),
    )?.ast.absolutePath;

    const sourceUnit: SourceUnit = this.contractBuildInfoOutput.output.sources[absolutePath].ast;

    const contractNode: ContractDefinition = sourceUnit.nodes.find(
      (node) => isNodeType("ContractDefinition", node) && node.name === name,
    ) as ContractDefinition;

    if (!contractNode) {
      throw new Error(`Contract ${name} not found in ${source}`);
    }

    const allFunctions: FunctionDefinition[] = [...findAll("FunctionDefinition", contractNode)];

    let functions: FunctionDefinition[];

    if (contractNode.contractKind === "library" && allFunctions.every((fn) => this.isPrivateOrInternal(fn))) {
      functions = allFunctions.filter((node) => this.isInternal(node));
    } else {
      functions = allFunctions.filter((node) => this.isPublicOrExternal(node));
    }

    return {
      name: contractNode.name,
      isAbstract: contractNode.abstract,
      contractKind: contractNode.contractKind,
      license: this.parseLicense(sourceUnit),
      documentations: await Promise.all([
        this.parseDocumentation([contractNode], ""),
        this.parseDocumentation([...findAll("EnumDefinition", contractNode)], ENUMS_BLOCK_NAME),
        this.parseDocumentation([...findAll("StructDefinition", contractNode)], STRUCTS_BLOCK_NAME),
        this.parseDocumentation([...findAll("EventDefinition", contractNode)], EVENTS_BLOCK_NAME),
        this.parseDocumentation([...findAll("ErrorDefinition", contractNode)], ERRORS_BLOCK_NAME),
        this.parseDocumentation(
          [...findAll("VariableDeclaration", contractNode)].filter(
            (node) => node.constant && this.isPublicOrExternal(node),
          ),
          CONSTANTS_BLOCK_NAME,
        ),
        this.parseDocumentation(
          [...findAll("VariableDeclaration", contractNode)].filter(
            (node) => !node.constant && this.isPublicOrExternal(node),
          ),
          STATE_VARIABLES_BLOCK_NAME,
        ),
        this.parseDocumentation([...findAll("ModifierDefinition", contractNode)], MODIFIERS_BLOCK_NAME),
        this.parseDocumentation(functions, FUNCTIONS_BLOCK_NAME),
      ]),
    };
  }

  isInternal(node: FunctionDefinition | VariableDeclaration): boolean {
    return node.visibility === "internal";
  }

  isPrivateOrInternal(node: FunctionDefinition | VariableDeclaration): boolean {
    return node.visibility === "private" || this.isInternal(node);
  }

  isPublicOrExternal(node: FunctionDefinition | VariableDeclaration): boolean {
    return node.visibility === "public" || node.visibility === "external";
  }

  parseSelector(node: FunctionDefinition | VariableDeclaration): string {
    return node.functionSelector ? ` (0x${node.functionSelector})` : "";
  }

  async applyPrettier(text: string): Promise<string> {
    return (await prettier.format(text, { parser: "solidity-parse", plugins: [pluginSolidity] })).trim();
  }

  parseHeader(node: any): string {
    switch (node.nodeType) {
      case "FunctionDefinition": {
        return `${node.name ? node.name : node.kind}${this.parseSelector(node)}`;
      }
      case "VariableDeclaration": {
        return `${node.name}${this.parseSelector(node)}`;
      }
      case "ContractDefinition": {
        return "";
      }
      default: {
        return node.name;
      }
    }
  }

  async parseFullSign(node: any): Promise<string> {
    switch (node.nodeType) {
      case "FunctionDefinition": {
        return this.parseFullFunctionSign(node);
      }
      case "VariableDeclaration": {
        return this.parseFullStateVariableSign(node);
      }
      case "EventDefinition": {
        return this.parseFullEventSign(node);
      }
      case "ErrorDefinition": {
        return this.parseFullErrorSign(node);
      }
      case "EnumDefinition": {
        return this.parseFullEnumSign(node);
      }
      case "StructDefinition": {
        return this.parseFullStructSign(node);
      }
      case "ModifierDefinition": {
        return this.parseFullModifierSign(node);
      }
      case "ContractDefinition": {
        return this.parseFullContractSign(node);
      }
      default: {
        throw new Error(`Unknown node type ${node.nodeType}`);
      }
    }
  }

  async parseDocumentation(nodes: Node[], name: string): Promise<DocumentationBlock> {
    return {
      blockName: name,
      documentation: await Promise.all(
        nodes.map(async (node) => ({
          fullSign: await this.parseFullSign(node),
          header: this.parseHeader(node),
          natSpecDocumentation: this.parseNatSpecDocumentation(node),
        })),
      ),
    };
  }

  removeTypePrefix(typeString: string): string {
    if (typeString.includes("enum ")) {
      return typeString.replace("enum ", "");
    } else if (typeString.includes("struct ")) {
      return typeString.replace("struct ", "");
    } else if (typeString.includes("contract ")) {
      return typeString.replace("contract ", "");
    }

    return typeString;
  }

  buildParameterString(
    parameters: VariableDeclaration[],
    delimiter: string = ", ",
    beginning: string = "",
    ending: string = "",
  ): string {
    return parameters
      .map((variableDeclaration) => {
        return `${beginning}${this.removeTypePrefix(variableDeclaration.typeDescriptions.typeString || "")}${
          variableDeclaration.storageLocation === "default" ? "" : ` ${variableDeclaration.storageLocation}`
        }${variableDeclaration.indexed ? " indexed" : ""}${
          variableDeclaration.name ? ` ${variableDeclaration.name}` : ""
        }${ending}`;
      })
      .join(delimiter);
  }

  parseModifiersString(modifiers: ModifierInvocation[]): string {
    return modifiers
      .map(
        (modifier) =>
          modifier.modifierName.name +
          (modifier.arguments
            ? `(${modifier.arguments
                .map((expression) => {
                  // EXPERIMENTAL
                  return this.parseStringFromSourceCode(expression.src);
                })
                .join(", ")})`
            : ""),
      )
      .join(" ");
  }

  async parseFullFunctionSign(functionDefinition: FunctionDefinition): Promise<string> {
    const kind = functionDefinition.kind;
    const functionName = functionDefinition.name.length === 0 ? "" : ` ${functionDefinition.name}`;
    const parameters = this.buildParameterString(functionDefinition.parameters.parameters);
    const visibility = kind === "constructor" ? "" : ` ${functionDefinition.visibility}`;
    const stateMutability =
      functionDefinition.stateMutability === "nonpayable" ? "" : ` ${functionDefinition.stateMutability}`;
    const modifiers =
      functionDefinition.modifiers.length === 0 ? "" : ` ${this.parseModifiersString(functionDefinition.modifiers)}`;

    const virtual = functionDefinition.virtual ? " virtual" : "";
    const overrides = functionDefinition.overrides ? " override" : "";
    const returns =
      functionDefinition.returnParameters.parameters.length === 0
        ? ""
        : ` returns (${this.buildParameterString(functionDefinition.returnParameters.parameters)})`;

    const formattedRes = await this.applyPrettier(
      `${kind}${functionName}(${parameters})${visibility}${stateMutability}${modifiers}${virtual}${overrides}${returns};`,
    );

    return formattedRes.substring(0, formattedRes.length - 1);
  }

  parseStringFromSourceCode(src: SourceLocation): string {
    const [start, end, id] = src.split(":").map((x) => parseInt(x));

    const sourceFile =
      this.contractBuildInfo.input.sources[
        Object.values(this.contractBuildInfoOutput.output.sources).find((source) => source.id === id)?.ast.absolutePath
      ].content;

    return Buffer.from(sourceFile, "utf-8")
      .subarray(start, start + end)
      .toString();
  }

  parseFullStateVariableSign(stateVariable: VariableDeclaration): string {
    let res = `${stateVariable.typeDescriptions.typeString}${
      stateVariable.mutability === "mutable" ? "" : ` ${stateVariable.mutability}`
    } ${stateVariable.name}`;

    // EXPERIMENTAL
    if (stateVariable.value) {
      res += ` = ${this.parseStringFromSourceCode(stateVariable.value.src)}`;
    }

    return res;
  }

  parseFullEventSign(eventDefinition: EventDefinition): string {
    return `event ${eventDefinition.name}(${this.buildParameterString(eventDefinition.parameters.parameters)})${
      eventDefinition.anonymous ? " anonymous" : ""
    }`;
  }

  parseFullErrorSign(errorDefinition: ErrorDefinition): string {
    return `error ${errorDefinition.name}(${this.buildParameterString(errorDefinition.parameters.parameters)})`;
  }

  parseFullStructSign(structDefinition: StructDefinition): string {
    return `struct ${structDefinition.name} {\n${this.buildParameterString(
      structDefinition.members,
      "\n",
      "\t",
      ";",
    )}\n}`;
  }

  parseFullEnumSign(enumDefinition: EnumDefinition): string {
    const parameters = enumDefinition.members.map((enumValue) => `\t ${enumValue.name}`).join(",\n");

    return `enum ${enumDefinition.name} {\n${parameters}\n}`;
  }

  parseFullModifierSign(modifierDefinition: ModifierDefinition): string {
    return `modifier ${modifierDefinition.name}(${this.buildParameterString(
      modifierDefinition.parameters.parameters,
    )})`;
  }

  parseFullContractSign(contractDefinition: ContractDefinition): string {
    return `${contractDefinition.abstract ? "abstract " : ""}${contractDefinition.contractKind} ${
      contractDefinition.name
    }${
      contractDefinition.baseContracts.length === 0
        ? ""
        : ` is ${contractDefinition.baseContracts.map((x) => x.baseName.name).join(", ")}`
    }`;
  }

  parseLicense(node: SourceUnit): string {
    return node.license || DEFAULT_LICENSE;
  }

  deleteCommentSymbols(text: string): string {
    let inCommentBlock = false;
    let startOfCommentBlock = false;
    let spacesToRemove = 0;

    return text
      .replace(/^\/\*\*([\s\S]*?)\*\/$/m, "$1")
      .trim()
      .replace(/^[ \t]*((\*{1,2}|\/{2,3}))+([ \t]*)(.*\n?)/gm, (match, p1, p2, p3, p4) => {
        if (p4.includes("```")) {
          inCommentBlock = !inCommentBlock;
          startOfCommentBlock = true;
          return p4;
        }

        if (inCommentBlock) {
          if (startOfCommentBlock) {
            spacesToRemove = p3.length;
            startOfCommentBlock = false;
          }

          return p3.substring(spacesToRemove) + p4;
        }

        return p4;
      })
      .trim();
  }

  replaceMultipleNewLinesWithOne(text: string): string {
    return text.replace(/\n{3,}/g, "\n");
  }

  getValidParentNodeToInheritDocumentation(
    baseNode: FunctionDefinition | VariableDeclaration,
  ): FunctionDefinition | null {
    if (!baseNode.baseFunctions || baseNode.baseFunctions.length !== 1) {
      return null;
    }

    const parentNode = this.deref("FunctionDefinition", baseNode.baseFunctions[0]);

    if (isNodeType("VariableDeclaration", baseNode)) {
      return parentNode;
    }

    if (baseNode.parameters.parameters.length !== parentNode.parameters.parameters.length) {
      return null;
    }

    for (let i = 0; i < baseNode.parameters.parameters.length; i++) {
      if (baseNode.parameters.parameters[i].name !== parentNode.parameters.parameters[i].name) {
        return null;
      }
    }

    return parentNode;
  }

  findFunctionDefinitionByContractName(
    node: FunctionDefinition | VariableDeclaration,
    contractName: string,
  ): FunctionDefinition | undefined {
    const contract = this.deref("ContractDefinition", node.scope);

    if (contract.canonicalName === contractName || contract.name === contractName) {
      return node as FunctionDefinition;
    }

    if (!node.baseFunctions) {
      return undefined;
    }

    for (let i = 0; i < node.baseFunctions.length; i++) {
      const baseFunction = this.deref("FunctionDefinition", node.baseFunctions[i]);
      const result = this.findFunctionDefinitionByContractName(baseFunction, contractName);

      if (result) {
        return result;
      }
    }
  }

  findModifierDefinitionByContractName(node: ModifierDefinition, contractName: string): ModifierDefinition | undefined {
    // Since we cannot access the scope (Contract) in which the modifier is defined,
    // we are looking for the topmost parent modifier documentation without the @inheritdoc tag.
    const inheritDocsRegex = /@inheritdoc (\w+)/gm;
    const matches = inheritDocsRegex.exec(node.documentation?.text!);

    if (!matches) {
      return node as ModifierDefinition;
    }

    if (!node.baseModifiers) {
      return undefined;
    }

    for (let i = 0; i < node.baseModifiers.length; i++) {
      const baseModifier = this.deref("ModifierDefinition", node.baseModifiers[i]);
      const result = this.findModifierDefinitionByContractName(baseModifier, contractName);

      if (result) {
        return result;
      }
    }
  }

  parseNameAndDescription(text: string): [name: string, description: string] {
    const nameAndDescriptionRegex = /^(\w+).? ([\s\S]*)?/gm;
    const matches = nameAndDescriptionRegex.exec(text);

    if (!matches) {
      throw new Error(`Invalid name and description: ${text}`);
    }

    const [, name, description] = matches;

    return [name, description];
  }

  joinDescriptionLines(text: string): string {
    return text.replace(/\n/g, " ");
  }

  parseNatSpecDocumentation(baseNode: any): NatSpecDocumentation {
    const natSpec: NatSpecDocumentation = {};
    const nodes = [baseNode];

    for (let i = 0; i < nodes.length; i++) {
      let node = nodes[i];

      if (!node.documentation) {
        const parentNode = this.getValidParentNodeToInheritDocumentation(node);

        if (parentNode) {
          nodes.push(parentNode);
        }

        continue;
      }

      let sourceText: string = this.parseStringFromSourceCode(node.documentation.src);

      if (sourceText) {
        const text = this.deleteCommentSymbols(sourceText);

        const natSpecRegex = /^(?:@(\w+|custom:[a-z][a-z-]*) )?((?:(?!^@(?:\w+|custom:[a-z][a-z-]*) )[^])*)/gm;

        const matches = [...text.matchAll(natSpecRegex)];

        for (let i = 0; i < matches.length; i++) {
          const [, tag = "notice", rawText] = matches[i];
          const text = this.replaceMultipleNewLinesWithOne(rawText);

          switch (tag) {
            case "title": {
              break;
            }
            case "author": {
              if (!natSpec.author) {
                natSpec.author = text;
              }

              break;
            }
            case "notice": {
              if (!natSpec.notice) {
                natSpec.notice = text;
              }

              break;
            }
            case "dev": {
              if (!natSpec.dev) {
                natSpec.dev = [];
              }

              natSpec.dev.push(text);

              break;
            }
            case "param": {
              natSpec.params ??= [];

              const [paramName, paramDescriptionRaw] = this.parseNameAndDescription(text);
              const paramDescription = this.joinDescriptionLines(paramDescriptionRaw);

              // if tag is already defined, skip it
              if (natSpec.params.find((param) => param.name == paramName)) {
                continue;
              }

              let params: VariableDeclaration[];

              if (node.nodeType === "EnumDefinition" || node.nodeType === "StructDefinition") {
                params = node.members;
              } else {
                params = node.parameters.parameters;
              }

              const variableDeclaration = params.find((param) => param.name == paramName);

              // so that tag was not found, and it may be a return tag
              if (!variableDeclaration) {
                // protection from infinite loop
                if (node.nodeType !== "EventDefinition") {
                  matches[i][1] = "return";
                  i--;
                }
                break;
              }

              const type = variableDeclaration.typeDescriptions?.typeString || undefined;

              natSpec.params.push({ name: paramName, type: type, description: paramDescription });

              break;
            }
            case "return": {
              // there is no return tag in events, so it is a param tag
              if (node.nodeType === "EventDefinition") {
                matches[i][1] = "param";
                i--;
                break;
              }

              natSpec.returns ??= [];

              let currentParameter: VariableDeclaration = isNodeType("FunctionDefinition", node)
                ? node.returnParameters.parameters[natSpec.returns.length]
                : node;

              if (!currentParameter) {
                break;
              }

              const currentParameterName = currentParameter.name;
              const type = currentParameter.typeDescriptions?.typeString!;

              // if name is not defined for return parameter
              if (!currentParameterName) {
                natSpec.returns.push({ type: type, description: this.joinDescriptionLines(text) });
              } else {
                const [paramName, paramDescriptionRaw] = this.parseNameAndDescription(text);
                const paramDescription = this.joinDescriptionLines(paramDescriptionRaw);

                if (paramName !== currentParameterName) {
                  break;
                }

                natSpec.returns.push({ name: paramName, type: type, description: paramDescription });
              }

              break;
            }
            case tag.startsWith("custom:") ? tag : "": {
              const customTag = tag.replace("custom:", "");

              natSpec.custom ??= {};
              natSpec.custom[customTag] = text;

              break;
            }
            case "inheritdoc": {
              const parentNodeRegex = /^(\w+)$/gm;
              const matches = parentNodeRegex.exec(text);

              if (!matches) {
                throw new Error(`Invalid inheritdoc tag: ${text}`);
              }

              const [, parentName] = matches;

              let parentNode;
              if (node.nodeType === "FunctionDefinition") {
                parentNode = this.findFunctionDefinitionByContractName(node, parentName);
              } else if (node.nodeType === "ModifierDefinition") {
                parentNode = this.findModifierDefinitionByContractName(node, parentName);
              } else if (node.nodeType === "VariableDeclaration") {
                break;
              }

              if (!parentNode) {
                throw new Error(`Invalid inheritdoc tag: ${text}`);
              }

              nodes.push(parentNode);

              break;
            }
            default: {
              throw new Error(`Unknown tag: ${tag}`);
            }
          }
        }
      }
    }

    return natSpec;
  }
}
