import assert from "assert";
import { randomUUID } from "crypto";
import { existsSync, readFileSync, unlinkSync } from "fs";
import path from "path";
import process from "process";

import { Command } from "@commander-js/extra-typings";
import type { Schema } from "jsonschema";
import { Validator as JsonValidator } from "jsonschema";
import type { Log as SarifLog, Result as SarifResult } from "sarif";

import {
  execCommand,
  findFileUpwards,
  isTruthy,
  loadSindriManifestJsonSchema,
} from "cli/utils";
import sindri from "lib";

export const lintCommand = new Command()
  .name("lint")
  .description("Lint the current Sindri project for potential issues.")
  .argument(
    "[directory]",
    "The directory, or subdirectory, of the project to lint.",
    ".",
  )
  .action(async (directory) => {
    // Track the error and warning counts as we go.
    let errorCount: number = 0;
    let warningCount: number = 0;

    // Load the Sindri Manifest JSON Schema.
    let sindriManifestJsonSchema: Schema | undefined;
    try {
      sindriManifestJsonSchema = loadSindriManifestJsonSchema();
      if (!sindriManifestJsonSchema) {
        throw new Error('No "sindri-manifest.json" file found.');
      }
      sindri.logger.debug('Successfully loaded "sindri-manifest.json".');
    } catch (error) {
      sindri.logger.error(
        'No "sindri-manifest.json" JSON Schema file found. Aborting.',
      );
      return process.exit(1);
    }

    // Find `sindri.json` and move into the root of the project directory.
    const directoryPath = path.resolve(directory);
    if (!existsSync(directoryPath)) {
      sindri.logger.error(
        `The "${directoryPath}" directory does not exist. Aborting.`,
      );
      return process.exit(1);
    }
    const sindriJsonPath = findFileUpwards(/^sindri.json$/i, directoryPath);
    if (!sindriJsonPath) {
      sindri.logger.error(
        `No "sindri.json" file was found in or above "${directoryPath}". Aborting.`,
      );
      return process.exit(1);
    }
    sindri.logger.debug(`Found "sindri.json" at "${sindriJsonPath}".`);
    const rootDirectory = path.dirname(sindriJsonPath);
    sindri.logger.debug(`Changing current directory to "${rootDirectory}".`);
    process.chdir(directoryPath);

    // Load `sindri.json`.
    let sindriJson: object = {};
    try {
      const sindriJsonContent = readFileSync(sindriJsonPath, {
        encoding: "utf-8",
      });
      sindriJson = JSON.parse(sindriJsonContent);
      sindri.logger.debug(
        `Successfully loaded "sindri.json" from "${sindriJsonPath}":`,
      );
      sindri.logger.debug(sindriJson);
    } catch (error) {
      sindri.logger.fatal(
        `Error loading "${sindriJsonPath}", perhaps it is not valid JSON?`,
      );
      sindri.logger.error(error);
      return process.exit(1);
    }

    // Determine the circuit type and manually discriminate the subschema type to narrow down the
    // schema so that the user gets more relevant validation errors.
    assert(Array.isArray(sindriManifestJsonSchema.anyOf));
    let subSchema: Schema | undefined;
    if (!("circuitType" in sindriJson) || !sindriJson.circuitType) {
      subSchema = undefined;
    } else if (sindriJson.circuitType === "circom") {
      subSchema = sindriManifestJsonSchema.anyOf.find((option: Schema) =>
        /circom/i.test(option["$ref"] ?? ""),
      );
    } else if (sindriJson.circuitType === "gnark") {
      subSchema = sindriManifestJsonSchema.anyOf.find((option: Schema) =>
        /gnark/i.test(option["$ref"] ?? ""),
      );
    } else if (sindriJson.circuitType === "halo2") {
      if (
        "halo2Version" in sindriJson &&
        sindriJson.halo2Version === "axiom-v0.2.2"
      ) {
        subSchema = sindriManifestJsonSchema.anyOf.find((option: Schema) =>
          /halo2axiomv022/i.test(option["$ref"] ?? ""),
        );
      } else if (
        "halo2Version" in sindriJson &&
        sindriJson.halo2Version === "axiom-v0.3.0"
      ) {
        subSchema = sindriManifestJsonSchema.anyOf.find((option: Schema) =>
          /halo2axiomv030/i.test(option["$ref"] ?? ""),
        );
      } else {
        // We can't discriminate the different halo2 manifests if there's not a valid `halo2Version`
        // so we'll just filter down the `anyOf` to the halo2 manifests instead of picking one.
        subSchema = {
          anyOf: sindriManifestJsonSchema.anyOf.filter((option: Schema) =>
            /halo2/i.test(option["$ref"] ?? ""),
          ),
        };
      }
    } else if (sindriJson.circuitType === "noir") {
      subSchema = sindriManifestJsonSchema.anyOf.find((option: Schema) =>
        /noir/i.test(option["$ref"] ?? ""),
      );
    }
    if (subSchema) {
      delete sindriManifestJsonSchema.anyOf;
      sindriManifestJsonSchema = { ...sindriManifestJsonSchema, ...subSchema };
    } else {
      sindri.logger.warn(
        `Circuit type is not configured in "${sindriJsonPath}" so some linting steps will be ` +
          "skipped and the manifest linting output will be very noisy. Please correct " +
          '"circuiType" in "sindri.json" and rerun "sindri lint" to get better linting.',
      );
      warningCount += 1;
    }
    const circuitType: "circom" | "gnark" | "halo2" | "noir" | null =
      "circuitType" in sindriJson &&
      typeof sindriJson.circuitType === "string" &&
      ["circom", "gnark", "halo2", "noir"].includes(sindriJson.circuitType)
        ? (sindriJson.circuitType as "circom" | "gnark" | "halo2" | "noir")
        : null;
    if (circuitType) {
      sindri.logger.debug(`Detected circuit type "${circuitType}".`);
    } else {
      sindri.logger.debug("No circuit type detected!");
    }

    // Validate `sindri.json`.
    const manifestValidator = new JsonValidator();
    const validationStatus = manifestValidator.validate(
      sindriJson,
      sindriManifestJsonSchema,
      { nestedErrors: true },
    );
    if (validationStatus.valid) {
      sindri.logger.info(`Sindri manifest file "${sindriJsonPath}" is valid.`);
    } else {
      sindri.logger.warn(
        `Sindri manifest file "${sindriJsonPath}" contains errors:`,
      );
      for (const error of validationStatus.errors) {
        const prefix =
          error.property
            .replace(/^instance/, "sindri.json")
            .replace(/\./g, ":") +
          (typeof error.schema === "object" && error.schema.title
            ? `:${error.schema.title}`
            : "");
        sindri.logger.error(`${prefix} ${error.message}`);
        errorCount += 1;
      }
    }

    // Check for a project README.
    const readmePath = path.join(rootDirectory, "README.md");
    if (!existsSync(readmePath)) {
      sindri.logger.warn(
        `No project README was found at "${readmePath}", consider adding one.`,
      );
      warningCount += 1;
    } else {
      sindri.logger.debug(`README file found at "${readmePath}".`);
    }

    // Run Circomspect for Circom circuits, unless it's been disabled.
    if (
      circuitType === "circom" &&
      isTruthy(process.env.SINDRI_LINT_DISABLE_CIRCOMSPECT ?? "false")
    ) {
      sindri.logger.warn(
        "Skipping Circomspect static analysis because " +
          `"SINDRI_LINT_DISABLE_CIRCOMSPECT=${process.env.SINDRI_LINT_DISABLE_CIRCOMSPECT}".`,
      );
    } else if (circuitType === "circom") {
      try {
        // Run Circomspect and parse the results.
        sindri.logger.info(
          "Running static analysis with Circomspect by Trail of Bits...",
        );
        const sarifFile = path.join(
          "/",
          "tmp",
          "sindri",
          `circomspect-${randomUUID()}.sarif`,
        );
        let sarif: SarifLog | undefined;
        let ranInDocker: boolean;
        try {
          // Check if circomspect supports the `--library` flag (introduced in v9.0.0).
          const { code: libraryPathSupportedTestCode } = await execCommand(
            "circomspect",
            ["--library", "/test/path/"],
            {
              tty: false,
            },
          );
          const libraryPathSupported: boolean =
            libraryPathSupportedTestCode === 0;
          // Conditionally include library paths if supported by the Ciromspect version.
          // It's important that this path logic is kept in sync with the Sindri backend.
          const libraryPathArgs: string[] = libraryPathSupported
            ? ["--library", ".", "--library", path.join(".", "node_modules")]
            : [];

          // Try to run circomspect.
          const circuitPath: string =
            "circuitPath" in sindriJson && sindriJson.circuitPath
              ? (sindriJson.circuitPath as string)
              : "circuit.circom";
          const { method } = await execCommand(
            "circomspect",
            [
              ...libraryPathArgs,
              "--level",
              "INFO",
              "--sarif-file",
              sarifFile,
              circuitPath,
            ],
            {
              cwd: rootDirectory,
              logger: sindri.logger,
              rootDirectory,
              tty: false,
            },
          );
          ranInDocker = method === "docker";
          if (method !== null) {
            sindri.logger.debug("Parsing Circomspect SARIF results.");
            const sarifContent = readFileSync(sarifFile, {
              encoding: "utf-8",
            });
            sarif = JSON.parse(sarifContent);
          } else {
            sindri.logger.warn(
              "Circomspect is not installed, skipping circomspect static analysis.\n" +
                "Please install Docker by following the directions at: " +
                "https://docs.docker.com/get-docker/\n" +
                "Or install Circomspect by following the directions at: " +
                "https://github.com/trailofbits/circomspect#installing-circomspect",
            );
            warningCount += 1;
          }
        } catch (error) {
          sindri.logger.fatal(
            `Error running Circomspect in "${rootDirectory}".`,
          );
          sindri.logger.error(error);
          errorCount += 1;
        } finally {
          try {
            unlinkSync(sarifFile);
          } catch {
            // The file might not have been created successfully, so this is probably fine.
            sindri.logger.debug(
              `Failed to delete temporary SARIF file "${sarifFile}".`,
            );
          }
        }

        if (sarif) {
          // Sort the results by file, line, and column; the order we want to display them in.
          const results: SarifResult[] = sarif.runs[0]?.results ?? [];
          results.sort((a: SarifResult, b: SarifResult) => {
            if (
              !a?.locations?.length ||
              !b?.locations?.length ||
              !a.locations[0]?.physicalLocation?.artifactLocation?.uri ||
              !b.locations[0]?.physicalLocation?.artifactLocation?.uri ||
              a.locations[0]?.physicalLocation?.region?.startLine == null ||
              b.locations[0]?.physicalLocation?.region?.startLine == null ||
              a.locations[0]?.physicalLocation?.region?.startColumn == null ||
              b.locations[0]?.physicalLocation?.region?.startColumn == null
            ) {
              return 0;
            }
            const uriComparison =
              a.locations[0].physicalLocation.artifactLocation.uri.localeCompare(
                b.locations[0].physicalLocation.artifactLocation.uri,
              );
            if (uriComparison !== 0) return uriComparison;
            const lineComparision =
              a.locations[0].physicalLocation.region.startLine -
              b.locations[0].physicalLocation.region.startLine;
            if (lineComparision !== 0) return lineComparision;
            const columnComparision =
              a.locations[0].physicalLocation.region.startColumn -
              b.locations[0].physicalLocation.region.startColumn;
            return columnComparision;
          });

          // Log out the circomspect results.
          let circomspectIssueFound = false;
          results.forEach((result: SarifResult) => {
            if (
              !result?.locations?.length ||
              !result.locations[0]?.physicalLocation?.artifactLocation?.uri ||
              result.locations[0]?.physicalLocation?.region?.startLine ==
                null ||
              result.locations[0]?.physicalLocation?.region?.startColumn ==
                null ||
              !result?.message?.text
            ) {
              sindri.logger.warn(
                "Circomspect result is missing required fields, skipping.",
              );
              sindri.logger.debug(result, "Missing Circomspect result fields:");
              return;
            }

            const filePath = path.relative(
              ranInDocker ? "/sindri/" : rootDirectory,
              result.locations[0].physicalLocation.artifactLocation.uri.replace(
                /^file:\/\//,
                "",
              ),
            );
            const { startColumn, startLine } =
              result.locations[0].physicalLocation.region;
            const logMessage =
              `${filePath}:${startLine}:${startColumn} ` +
              `${result.message.text} [Circomspect: ${result.ruleId}]`;
            if (result.level === "error") {
              sindri.logger.error(logMessage);
              circomspectIssueFound = true;
              errorCount += 1;
            } else if (result.level === "warning") {
              sindri.logger.warn(logMessage);
              circomspectIssueFound = true;
              warningCount += 1;
            } else {
              sindri.logger.debug(logMessage);
            }
          });
          if (!circomspectIssueFound) {
            sindri.logger.info("No issues found with Circomspect, good job!");
          }
        }
      } catch (error) {
        sindri.logger.fatal("Error running Circomspect, aborting.");
        sindri.logger.debug(error);
        return process.exit(1);
      }
    }

    // Summarize the errors and warnings.
    if (errorCount === 0 && warningCount === 0) {
      sindri.logger.info("No issues found, good job!");
    } else {
      sindri.logger.warn(
        `Found ${errorCount + warningCount} problems ` +
          `(${errorCount} errors, ${warningCount} warnings).`,
      );
      if (errorCount > 0) {
        sindri.logger.error(`Linting failed with ${errorCount} errors.`);
        return process.exit(1);
      }
    }
  });
