import * as fs from "fs";

import * as github from "@actions/github";
import test from "ava";
import * as sinon from "sinon";

import * as actionsUtil from "./actions-util";
import { AnalysisKind } from "./analyses";
import { GitHubApiDetails } from "./api-client";
import * as apiClient from "./api-client";
import { createStubCodeQL } from "./codeql";
import { Config } from "./config-utils";
import { cleanupAndUploadDatabases } from "./database-upload";
import * as gitUtils from "./git-utils";
import { KnownLanguage } from "./languages";
import { RepositoryNwo } from "./repository";
import {
  checkExpectedLogMessages,
  createFeatures,
  createTestConfig,
  getRecordingLogger,
  LoggedMessage,
  setupActionsVars,
  setupTests,
} from "./testing-utils";
import {
  GitHubVariant,
  HTTPError,
  initializeEnvironment,
  withTmpDir,
} from "./util";

setupTests(test);

test.beforeEach(() => {
  initializeEnvironment("1.2.3");
});

const testRepoName: RepositoryNwo = { owner: "github", repo: "example" };
const testApiDetails: GitHubApiDetails = {
  auth: "1234",
  url: "https://github.com",
  apiURL: undefined,
};

function getTestConfig(tmpDir: string): Config {
  return createTestConfig({
    languages: [KnownLanguage.javascript],
    dbLocation: tmpDir,
  });
}

async function mockHttpRequests(databaseUploadStatusCode: number) {
  // Passing an auth token is required, so we just use a dummy value
  const client = github.getOctokit("123");

  const requestSpy = sinon.stub(client, "request");

  const url =
    "POST /repos/:owner/:repo/code-scanning/codeql/databases/:language?name=:name&commit_oid=:commit_oid";
  const databaseUploadSpy = requestSpy.withArgs(url);
  if (databaseUploadStatusCode < 300) {
    databaseUploadSpy.resolves(undefined);
  } else {
    databaseUploadSpy.throws(
      new HTTPError("some error message", databaseUploadStatusCode),
    );
  }

  sinon.stub(apiClient, "getApiClient").value(() => client);

  return databaseUploadSpy;
}

function getCodeQL() {
  return createStubCodeQL({
    async databaseBundle(_: string, outputFilePath: string) {
      fs.writeFileSync(outputFilePath, "");
    },
    async databaseCleanupCluster() {
      // Do nothing, as we are not testing cleanup here.
    },
  });
}

test.serial(
  "Abort database upload if 'upload-database' input set to false",
  async (t) => {
    await withTmpDir(async (tmpDir) => {
      setupActionsVars(tmpDir, tmpDir);
      sinon
        .stub(actionsUtil, "getRequiredInput")
        .withArgs("upload-database")
        .returns("false");
      sinon.stub(gitUtils, "isAnalyzingDefaultBranch").resolves(true);

      const loggedMessages: LoggedMessage[] = [];
      await cleanupAndUploadDatabases(
        testRepoName,
        getCodeQL(),
        getTestConfig(tmpDir),
        testApiDetails,
        createFeatures([]),
        getRecordingLogger(loggedMessages),
      );
      checkExpectedLogMessages(t, loggedMessages, [
        "Database upload disabled in workflow. Skipping upload.",
      ]);
    });
  },
);

test.serial(
  "Abort database upload if 'analysis-kinds: code-scanning' is not enabled",
  async (t) => {
    await withTmpDir(async (tmpDir) => {
      setupActionsVars(tmpDir, tmpDir);
      sinon
        .stub(actionsUtil, "getRequiredInput")
        .withArgs("upload-database")
        .returns("true");
      sinon.stub(gitUtils, "isAnalyzingDefaultBranch").resolves(true);

      await mockHttpRequests(201);

      const loggedMessages: LoggedMessage[] = [];
      await cleanupAndUploadDatabases(
        testRepoName,
        getCodeQL(),
        {
          ...getTestConfig(tmpDir),
          analysisKinds: [AnalysisKind.CodeQuality],
        },
        testApiDetails,
        createFeatures([]),
        getRecordingLogger(loggedMessages),
      );
      checkExpectedLogMessages(t, loggedMessages, [
        "Not uploading database because 'analysis-kinds: code-scanning' is not enabled.",
      ]);
    });
  },
);

test.serial("Abort database upload if running against GHES", async (t) => {
  await withTmpDir(async (tmpDir) => {
    setupActionsVars(tmpDir, tmpDir);
    sinon
      .stub(actionsUtil, "getRequiredInput")
      .withArgs("upload-database")
      .returns("true");
    sinon.stub(gitUtils, "isAnalyzingDefaultBranch").resolves(true);

    const config = getTestConfig(tmpDir);
    config.gitHubVersion = { type: GitHubVariant.GHES, version: "3.0" };

    const loggedMessages: LoggedMessage[] = [];
    await cleanupAndUploadDatabases(
      testRepoName,
      getCodeQL(),
      config,
      testApiDetails,
      createFeatures([]),
      getRecordingLogger(loggedMessages),
    );
    checkExpectedLogMessages(t, loggedMessages, [
      "Not running against github.com or GHEC-DR. Skipping upload.",
    ]);
  });
});

test.serial(
  "Abort database upload if not analyzing default branch",
  async (t) => {
    await withTmpDir(async (tmpDir) => {
      setupActionsVars(tmpDir, tmpDir);
      sinon
        .stub(actionsUtil, "getRequiredInput")
        .withArgs("upload-database")
        .returns("true");
      sinon.stub(gitUtils, "isAnalyzingDefaultBranch").resolves(false);

      const loggedMessages: LoggedMessage[] = [];
      await cleanupAndUploadDatabases(
        testRepoName,
        getCodeQL(),
        getTestConfig(tmpDir),
        testApiDetails,
        createFeatures([]),
        getRecordingLogger(loggedMessages),
      );
      checkExpectedLogMessages(t, loggedMessages, [
        "Not analyzing default branch. Skipping upload.",
      ]);
    });
  },
);

test.serial(
  "Don't crash if uploading a database fails with a non-retryable error",
  async (t) => {
    await withTmpDir(async (tmpDir) => {
      setupActionsVars(tmpDir, tmpDir);
      sinon
        .stub(actionsUtil, "getRequiredInput")
        .withArgs("upload-database")
        .returns("true");
      sinon.stub(gitUtils, "isAnalyzingDefaultBranch").resolves(true);

      const databaseUploadSpy = await mockHttpRequests(422);

      const loggedMessages: LoggedMessage[] = [];
      await cleanupAndUploadDatabases(
        testRepoName,
        getCodeQL(),
        getTestConfig(tmpDir),
        testApiDetails,
        createFeatures([]),
        getRecordingLogger(loggedMessages),
      );

      checkExpectedLogMessages(t, loggedMessages, [
        "Failed to upload database for javascript: some error message",
      ]);

      // Non-retryable errors should not be retried.
      t.is(databaseUploadSpy.callCount, 1);
    });
  },
);

test.serial(
  "Don't crash if uploading a database fails with a retryable error",
  async (t) => {
    await withTmpDir(async (tmpDir) => {
      setupActionsVars(tmpDir, tmpDir);
      sinon
        .stub(actionsUtil, "getRequiredInput")
        .withArgs("upload-database")
        .returns("true");
      sinon.stub(gitUtils, "isAnalyzingDefaultBranch").resolves(true);

      const databaseUploadSpy = await mockHttpRequests(500);

      // Stub setTimeout to fire immediately to avoid real delays from retry backoff.
      const originalSetTimeout = global.setTimeout;
      const setTimeoutStub = sinon
        .stub(global, "setTimeout")
        .callsFake((fn: () => void) => originalSetTimeout(fn, 0));

      const loggedMessages: LoggedMessage[] = [];
      await cleanupAndUploadDatabases(
        testRepoName,
        getCodeQL(),
        getTestConfig(tmpDir),
        testApiDetails,
        createFeatures([]),
        getRecordingLogger(loggedMessages),
      );

      checkExpectedLogMessages(t, loggedMessages, [
        "Failed to upload database for javascript: some error message",
      ]);

      // Retryable errors should be retried the expected number of times.
      t.is(databaseUploadSpy.callCount, 4);

      // setTimeout should have been called with the expected backoff delays.
      const setTimeoutDelays = setTimeoutStub.args.map(
        (args) => args[1] as number,
      );
      t.deepEqual(setTimeoutDelays, [15_000, 30_000, 60_000]);
    });
  },
);

test.serial("Successfully uploading a database to github.com", async (t) => {
  await withTmpDir(async (tmpDir) => {
    setupActionsVars(tmpDir, tmpDir);
    sinon
      .stub(actionsUtil, "getRequiredInput")
      .withArgs("upload-database")
      .returns("true");
    sinon.stub(gitUtils, "isAnalyzingDefaultBranch").resolves(true);

    await mockHttpRequests(201);

    const loggedMessages: LoggedMessage[] = [];
    await cleanupAndUploadDatabases(
      testRepoName,
      getCodeQL(),
      getTestConfig(tmpDir),
      testApiDetails,
      createFeatures([]),
      getRecordingLogger(loggedMessages),
    );
    checkExpectedLogMessages(t, loggedMessages, [
      "Successfully uploaded database for javascript",
    ]);
  });
});

test.serial("Successfully uploading a database to GHEC-DR", async (t) => {
  await withTmpDir(async (tmpDir) => {
    setupActionsVars(tmpDir, tmpDir);
    sinon
      .stub(actionsUtil, "getRequiredInput")
      .withArgs("upload-database")
      .returns("true");
    sinon.stub(gitUtils, "isAnalyzingDefaultBranch").resolves(true);

    const databaseUploadSpy = await mockHttpRequests(201);

    const loggedMessages: LoggedMessage[] = [];
    await cleanupAndUploadDatabases(
      testRepoName,
      getCodeQL(),
      getTestConfig(tmpDir),
      {
        auth: "1234",
        url: "https://tenant.ghe.com",
        apiURL: undefined,
      },
      createFeatures([]),
      getRecordingLogger(loggedMessages),
    );
    checkExpectedLogMessages(t, loggedMessages, [
      "Successfully uploaded database for javascript",
    ]);
    t.assert(
      databaseUploadSpy.calledOnceWith(
        sinon.match.string,
        sinon.match.has("baseUrl", "https://uploads.tenant.ghe.com"),
      ),
    );
  });
});
