import * as fs from "fs";
import path from "path";
import { performance } from "perf_hooks";

import * as core from "@actions/core";

import * as actionsUtil from "./actions-util";
import * as analyses from "./analyses";
import {
  CodeQLAnalysisError,
  dbIsFinalized,
  QueriesStatusReport,
  runFinalize,
  runQueries,
  setupDiffInformedQueryRun,
  warnIfGoInstalledAfterInit,
} from "./analyze";
import { getApiDetails, getGitHubVersion } from "./api-client";
import { runAutobuild } from "./autobuild";
import { getTotalCacheSize, shouldStoreCache } from "./caching-utils";
import { getCodeQL } from "./codeql";
import { Config, getConfig } from "./config-utils";
import {
  cleanupAndUploadDatabases,
  DatabaseUploadResult,
} from "./database-upload";
import {
  DependencyCacheUploadStatusReport,
  uploadDependencyCaches,
} from "./dependency-caching";
import { EnvVar } from "./environment";
import { initFeatures } from "./feature-flags";
import { KnownLanguage } from "./languages";
import { getActionsLogger, Logger } from "./logging";
import { cleanupAndUploadOverlayBaseDatabaseToCache } from "./overlay";
import { getRepositoryNwo } from "./repository";
import * as statusReport from "./status-report";
import {
  ActionName,
  createStatusReportBase,
  DatabaseCreationTimings,
  getActionsStatus,
  sendUnhandledErrorStatusReport,
  StatusReportBase,
} from "./status-report";
import {
  cleanupTrapCaches,
  TrapCacheCleanupStatusReport,
  uploadTrapCaches,
} from "./trap-caching";
import * as uploadLib from "./upload-lib";
import { UploadResult } from "./upload-lib";
import { postProcessAndUploadSarif } from "./upload-sarif";
import * as util from "./util";

interface AnalysisStatusReport
  extends uploadLib.UploadStatusReport,
    QueriesStatusReport {}

interface FinishStatusReport
  extends StatusReportBase,
    DatabaseCreationTimings,
    AnalysisStatusReport {
  dependency_caching_upload_results?: DependencyCacheUploadStatusReport;
  database_upload_results: DatabaseUploadResult[];
}

interface FinishWithTrapUploadStatusReport extends FinishStatusReport {
  /** Size of TRAP caches that we uploaded, in bytes. */
  trap_cache_upload_size_bytes: number;
  /** Time taken to upload TRAP caches, in milliseconds. */
  trap_cache_upload_duration_ms: number;
}

async function sendStatusReport(
  startedAt: Date,
  config: Config | undefined,
  stats: AnalysisStatusReport | undefined,
  error: Error | undefined,
  trapCacheUploadTime: number | undefined,
  dbCreationTimings: DatabaseCreationTimings | undefined,
  didUploadTrapCaches: boolean,
  trapCacheCleanup: TrapCacheCleanupStatusReport | undefined,
  dependencyCacheResults: DependencyCacheUploadStatusReport | undefined,
  databaseUploadResults: DatabaseUploadResult[],
  logger: Logger,
) {
  const status = getActionsStatus(error, stats?.analyze_failure_language);
  const statusReportBase = await createStatusReportBase(
    ActionName.Analyze,
    status,
    startedAt,
    config,
    await util.checkDiskUsage(logger),
    logger,
    error?.message,
    error?.stack,
  );
  if (statusReportBase !== undefined) {
    const report: FinishStatusReport = {
      ...statusReportBase,
      ...(stats || {}),
      ...(dbCreationTimings || {}),
      ...(trapCacheCleanup || {}),
      dependency_caching_upload_results: dependencyCacheResults,
      database_upload_results: databaseUploadResults,
    };
    if (config && didUploadTrapCaches) {
      const trapCacheUploadStatusReport: FinishWithTrapUploadStatusReport = {
        ...report,
        trap_cache_upload_duration_ms: Math.round(trapCacheUploadTime || 0),
        trap_cache_upload_size_bytes: Math.round(
          await getTotalCacheSize(Object.values(config.trapCaches), logger),
        ),
      };
      await statusReport.sendStatusReport(trapCacheUploadStatusReport);
    } else {
      await statusReport.sendStatusReport(report);
    }
  }
}

// `expect-error` should only be set to a non-false value by the CodeQL Action PR checks.
function hasBadExpectErrorInput(): boolean {
  return (
    actionsUtil.getOptionalInput("expect-error") !== "false" &&
    !util.isInTestMode()
  );
}

/**
 * Returns whether any TRAP files exist under the `db-go` folder,
 * indicating whether Go extraction has extracted at least one file.
 */
function doesGoExtractionOutputExist(config: Config): boolean {
  const golangDbDirectory = util.getCodeQLDatabasePath(
    config,
    KnownLanguage.go,
  );
  const trapDirectory = path.join(golangDbDirectory, "trap", KnownLanguage.go);
  return (
    fs.existsSync(trapDirectory) &&
    fs
      .readdirSync(trapDirectory)
      .some((fileName) =>
        [
          ".trap",
          ".trap.gz",
          ".trap.br",
          ".trap.tar.gz",
          ".trap.tar.br",
          ".trap.tar",
        ].some((ext) => fileName.endsWith(ext)),
      )
  );
}

/**
 * We attempt to autobuild Go to preserve compatibility for users who have
 * set up Go using a legacy scanning style CodeQL workflow, i.e. one without
 * an autobuild step or manual build steps.
 *
 * - We detect whether an autobuild step is present by checking the
 * `CODEQL_ACTION_DID_AUTOBUILD_GOLANG` environment variable, which is set
 * when the autobuilder is invoked.
 * - We detect whether the Go database has already been finalized in case it
 * has been manually set in a prior Action step.
 * - We approximate whether manual build steps are present by looking at
 * whether any extraction output already exists for Go.
 */
async function runAutobuildIfLegacyGoWorkflow(config: Config, logger: Logger) {
  if (!config.languages.includes(KnownLanguage.go)) {
    return;
  }
  if (config.buildMode) {
    logger.debug(
      "Skipping legacy Go autobuild since a build mode has been specified.",
    );
    return;
  }
  if (process.env[EnvVar.DID_AUTOBUILD_GOLANG] === "true") {
    logger.debug("Won't run Go autobuild since it has already been run.");
    return;
  }
  if (dbIsFinalized(config, KnownLanguage.go, logger)) {
    logger.debug(
      "Won't run Go autobuild since there is already a finalized database for Go.",
    );
    return;
  }
  // This captures whether a user has added manual build steps for Go
  if (doesGoExtractionOutputExist(config)) {
    logger.debug(
      "Won't run Go autobuild since at least one file of Go code has already been extracted.",
    );
    // If the user has run the manual build step, and has set the `CODEQL_EXTRACTOR_GO_BUILD_TRACING`
    // variable, we suggest they remove it from their workflow.
    if ("CODEQL_EXTRACTOR_GO_BUILD_TRACING" in process.env) {
      logger.warning(
        `The CODEQL_EXTRACTOR_GO_BUILD_TRACING environment variable has no effect on workflows with manual build steps, so we recommend that you remove it from your workflow.`,
      );
    }
    return;
  }
  logger.debug(
    "Running Go autobuild because extraction output (TRAP files) for Go code has not been found.",
  );
  await runAutobuild(config, KnownLanguage.go, logger);
}

async function run(startedAt: Date) {
  // To capture errors appropriately, keep as much code within the try-catch as
  // possible, and only use safe functions outside.

  let uploadResults:
    | Partial<Record<analyses.AnalysisKind, UploadResult>>
    | undefined = undefined;
  let runStats: QueriesStatusReport | undefined = undefined;
  let config: Config | undefined = undefined;
  let trapCacheCleanupTelemetry: TrapCacheCleanupStatusReport | undefined =
    undefined;
  let trapCacheUploadTime: number | undefined = undefined;
  let dbCreationTimings: DatabaseCreationTimings | undefined = undefined;
  let didUploadTrapCaches = false;
  let dependencyCacheResults: DependencyCacheUploadStatusReport | undefined;
  let databaseUploadResults: DatabaseUploadResult[] = [];
  const logger = getActionsLogger();

  try {
    util.initializeEnvironment(actionsUtil.getActionVersion());

    // Make inputs accessible in the `post` step, details at
    // https://github.com/github/codeql-action/issues/2553
    actionsUtil.persistInputs();

    const statusReportBase = await createStatusReportBase(
      ActionName.Analyze,
      "starting",
      startedAt,
      config,
      await util.checkDiskUsage(logger),
      logger,
    );
    if (statusReportBase !== undefined) {
      await statusReport.sendStatusReport(statusReportBase);
    }

    config = await getConfig(actionsUtil.getTemporaryDirectory(), logger);
    if (config === undefined) {
      throw new util.ConfigurationError(
        "Config file could not be found at expected location. Has the 'init' action been called?",
      );
    }

    const codeql = await getCodeQL(config.codeQLCmd);

    if (hasBadExpectErrorInput()) {
      throw new util.ConfigurationError(
        "`expect-error` input parameter is for internal use only. It should only be set by codeql-action or a fork.",
      );
    }

    // Unset the CODEQL_PROXY_* environment variables when using older CodeQL
    // CLIs, as they are not needed and can cause issues.
    if (
      process.env.CODEQL_PROXY_HOST === "" &&
      !(await util.codeQlVersionAtLeast(codeql, "2.20.7"))
    ) {
      delete process.env.CODEQL_PROXY_HOST;
      delete process.env.CODEQL_PROXY_PORT;
      delete process.env.CODEQL_PROXY_CA_CERTIFICATE;
    }

    if (actionsUtil.getOptionalInput("cleanup-level")) {
      logger.info(
        "The 'cleanup-level' input is ignored since the CodeQL Action now automatically " +
          "manages database cleanup. This input can safely be removed from your workflow.",
      );
    }

    const apiDetails = getApiDetails();
    const outputDir = actionsUtil.getRequiredInput("output");
    core.exportVariable(EnvVar.SARIF_RESULTS_OUTPUT_DIR, outputDir);
    const threads = util.getThreadsFlag(
      actionsUtil.getOptionalInput("threads") || process.env["CODEQL_THREADS"],
      logger,
    );

    const repositoryNwo = getRepositoryNwo();

    const gitHubVersion = await getGitHubVersion();

    util.checkActionVersion(actionsUtil.getActionVersion(), gitHubVersion);

    const features = initFeatures(
      gitHubVersion,
      repositoryNwo,
      actionsUtil.getTemporaryDirectory(),
      logger,
    );

    const memory = util.getMemoryFlag(
      actionsUtil.getOptionalInput("ram") || process.env["CODEQL_RAM"],
      logger,
    );

    // Setup diff informed analysis if needed (based on whether init created the file)
    const diffRangePackDir = await setupDiffInformedQueryRun(logger);

    await warnIfGoInstalledAfterInit(config, logger);
    await runAutobuildIfLegacyGoWorkflow(config, logger);

    dbCreationTimings = await runFinalize(
      features,
      outputDir,
      threads,
      memory,
      codeql,
      config,
      logger,
    );

    if (actionsUtil.getRequiredInput("skip-queries") !== "true") {
      // Warn if the removed `add-snippets` input is used.
      if (actionsUtil.getOptionalInput("add-snippets") !== undefined) {
        logger.warning(
          "The `add-snippets` input has been removed and no longer has any effect.",
        );
      }

      runStats = await runQueries(
        outputDir,
        memory,
        threads,
        diffRangePackDir,
        actionsUtil.getOptionalInput("category"),
        codeql,
        config,
        logger,
        features,
      );
    }

    const dbLocations: { [lang: string]: string } = {};
    for (const language of config.languages) {
      dbLocations[language] = util.getCodeQLDatabasePath(config, language);
    }
    core.setOutput("db-locations", dbLocations);
    core.setOutput("sarif-output", path.resolve(outputDir));
    const uploadKind = actionsUtil.getUploadValue(
      actionsUtil.getOptionalInput("upload"),
    );
    if (runStats) {
      const checkoutPath = actionsUtil.getRequiredInput("checkout_path");
      const category = actionsUtil.getOptionalInput("category");

      uploadResults = await postProcessAndUploadSarif(
        logger,
        features,
        uploadKind,
        checkoutPath,
        outputDir,
        category,
        actionsUtil.getOptionalInput("post-processed-sarif-path"),
      );

      // Set the SARIF id outputs only if we have results for them, to avoid
      // having keys with empty values in the action output.
      if (uploadResults[analyses.AnalysisKind.CodeScanning] !== undefined) {
        core.setOutput(
          "sarif-id",
          uploadResults[analyses.AnalysisKind.CodeScanning].sarifID,
        );
      }
      if (uploadResults[analyses.AnalysisKind.CodeQuality] !== undefined) {
        core.setOutput(
          "quality-sarif-id",
          uploadResults[analyses.AnalysisKind.CodeQuality].sarifID,
        );
      }
    } else {
      logger.info("Not uploading results");
    }

    // Possibly upload the overlay-base database to actions cache.
    // Note: Take care with the ordering of this call since databases may be cleaned up
    // at the `overlay` level.
    await cleanupAndUploadOverlayBaseDatabaseToCache(codeql, config, logger);

    // Possibly upload the database bundles for remote queries.
    // Note: Take care with the ordering of this call since databases may be cleaned up
    // at the `overlay` or `clear` level.
    databaseUploadResults = await cleanupAndUploadDatabases(
      repositoryNwo,
      codeql,
      config,
      apiDetails,
      features,
      logger,
    );

    // Possibly upload the TRAP caches for later re-use
    const trapCacheUploadStartTime = performance.now();
    didUploadTrapCaches = await uploadTrapCaches(codeql, config, logger);
    trapCacheUploadTime = performance.now() - trapCacheUploadStartTime;

    // Clean up TRAP caches
    trapCacheCleanupTelemetry = await cleanupTrapCaches(
      config,
      features,
      logger,
    );

    // Store dependency cache(s) if dependency caching is enabled.
    if (shouldStoreCache(config.dependencyCachingEnabled)) {
      dependencyCacheResults = await uploadDependencyCaches(
        codeql,
        features,
        config,
        logger,
      );
    }

    // We don't upload results in test mode, so don't wait for processing
    if (util.isInTestMode()) {
      logger.debug("In test mode. Waiting for processing is disabled.");
    } else if (
      uploadResults?.[analyses.AnalysisKind.CodeScanning] !== undefined &&
      actionsUtil.getRequiredInput("wait-for-processing") === "true"
    ) {
      await uploadLib.waitForProcessing(
        getRepositoryNwo(),
        uploadResults[analyses.AnalysisKind.CodeScanning].sarifID,
        getActionsLogger(),
      );
    }
    // If we did not throw an error yet here, but we expect one, throw it.
    if (actionsUtil.getOptionalInput("expect-error") === "true") {
      core.setFailed(
        `expect-error input was set to true but no error was thrown.`,
      );
    }
    core.exportVariable(EnvVar.ANALYZE_DID_COMPLETE_SUCCESSFULLY, "true");
  } catch (unwrappedError) {
    const error = util.wrapError(unwrappedError);
    if (
      actionsUtil.getOptionalInput("expect-error") !== "true" ||
      hasBadExpectErrorInput()
    ) {
      core.setFailed(error.message);
    }

    await sendStatusReport(
      startedAt,
      config,
      error instanceof CodeQLAnalysisError
        ? error.queriesStatusReport
        : undefined,
      error instanceof CodeQLAnalysisError ? error.error : error,
      trapCacheUploadTime,
      dbCreationTimings,
      didUploadTrapCaches,
      trapCacheCleanupTelemetry,
      dependencyCacheResults,
      databaseUploadResults,
      logger,
    );
    return;
  }

  if (
    runStats !== undefined &&
    uploadResults?.[analyses.AnalysisKind.CodeScanning] !== undefined
  ) {
    await sendStatusReport(
      startedAt,
      config,
      {
        ...runStats,
        ...uploadResults[analyses.AnalysisKind.CodeScanning].statusReport,
      },
      undefined,
      trapCacheUploadTime,
      dbCreationTimings,
      didUploadTrapCaches,
      trapCacheCleanupTelemetry,
      dependencyCacheResults,
      databaseUploadResults,
      logger,
    );
  } else if (runStats !== undefined) {
    await sendStatusReport(
      startedAt,
      config,
      { ...runStats },
      undefined,
      trapCacheUploadTime,
      dbCreationTimings,
      didUploadTrapCaches,
      trapCacheCleanupTelemetry,
      dependencyCacheResults,
      databaseUploadResults,
      logger,
    );
  } else {
    await sendStatusReport(
      startedAt,
      config,
      undefined,
      undefined,
      trapCacheUploadTime,
      dbCreationTimings,
      didUploadTrapCaches,
      trapCacheCleanupTelemetry,
      dependencyCacheResults,
      databaseUploadResults,
      logger,
    );
  }
}

// Module-level startedAt so it can be accessed by runWrapper for error reporting
const startedAt = new Date();
export const runPromise = run(startedAt);

async function runWrapper() {
  const logger = getActionsLogger();
  try {
    await runPromise;
  } catch (error) {
    core.setFailed(`analyze action failed: ${util.getErrorMessage(error)}`);
    await sendUnhandledErrorStatusReport(
      ActionName.Analyze,
      startedAt,
      error,
      logger,
    );
  }
  await util.checkForTimeout();
}

void runWrapper();
