import * as fs from "fs";

import * as actionsUtil from "./actions-util";
import type { PullRequestBranches } from "./actions-util";
import { getApiClient, getGitHubVersion } from "./api-client";
import type { CodeQL } from "./codeql";
import { Feature, FeatureEnablement } from "./feature-flags";
import { Logger, withGroupAsync } from "./logging";
import { getRepositoryNwoFromEnv } from "./repository";
import { getErrorMessage, GitHubVariant, satisfiesGHESVersion } from "./util";

/**
 * This interface is an abbreviated version of the file diff object returned by
 * the GitHub API.
 */
interface FileDiff {
  filename: string;
  changes: number;
  // A patch may be absent if the file is binary, if the file diff is too large,
  // or if the file is unchanged.
  patch?: string | undefined;
}

/**
 * Get the branches to use for diff-informed analysis.
 *
 * @returns If the action should perform diff-informed analysis, return
 * the base and head branches that should be used to compute the diff ranges.
 * Otherwise return `undefined`.
 */
export async function getDiffInformedAnalysisBranches(
  codeql: CodeQL,
  features: FeatureEnablement,
  logger: Logger,
): Promise<PullRequestBranches | undefined> {
  if (!(await features.getValue(Feature.DiffInformedQueries, codeql))) {
    return undefined;
  }

  const gitHubVersion = await getGitHubVersion();
  if (
    gitHubVersion.type === GitHubVariant.GHES &&
    satisfiesGHESVersion(gitHubVersion.version, "<3.19", true)
  ) {
    return undefined;
  }

  const branches = actionsUtil.getPullRequestBranches();
  if (!branches) {
    logger.info(
      "Not performing diff-informed analysis " +
        "because we are not analyzing a pull request.",
    );
  }
  return branches;
}

/**
 * Prepares the diff ranges needed for diff-informed analysis for the current
 * run.
 *
 * @returns `true` if the diff ranges were successfully computed and persisted
 *   and are therefore available for use, `false` otherwise.
 */
export async function prepareDiffInformedAnalysis(
  codeql: CodeQL,
  features: FeatureEnablement,
  logger: Logger,
): Promise<boolean> {
  let branches: PullRequestBranches | undefined;
  try {
    branches = await getDiffInformedAnalysisBranches(codeql, features, logger);
  } catch (e) {
    // If we cannot determine whether diff-informed analysis applies (for
    // example, because a feature-flag lookup failed), treat it as not
    // applicable rather than triggering the overlay fallback.
    logger.warning(
      `Failed to determine branch information for diff-informed analysis: ${getErrorMessage(e)}`,
    );
    return false;
  }
  if (!branches) {
    return false;
  }

  return await withGroupAsync("Computing PR diff ranges", async () => {
    try {
      return await computeAndPersistDiffRanges(branches, logger);
    } catch (e) {
      logger.warning(
        `Failed to compute diff-informed analysis ranges: ${getErrorMessage(e)}`,
      );
      return false;
    }
  });
}

export interface DiffThunkRange {
  /** Relative path from the repository root, using forward slashes as separators. */
  path: string;
  startLine: number;
  endLine: number;
}

export function writeDiffRangesJsonFile(
  logger: Logger,
  ranges: DiffThunkRange[],
): void {
  const jsonContents = JSON.stringify(ranges, null, 2);
  const jsonFilePath = actionsUtil.getDiffRangesJsonFilePath();
  fs.writeFileSync(jsonFilePath, jsonContents);
  logger.debug(
    `Wrote pr-diff-range JSON file to ${jsonFilePath}:\n${jsonContents}`,
  );
}

export function readDiffRangesJsonFile(
  logger: Logger,
): DiffThunkRange[] | undefined {
  const jsonFilePath = actionsUtil.getDiffRangesJsonFilePath();
  if (!fs.existsSync(jsonFilePath)) {
    logger.debug(`Diff ranges JSON file does not exist at ${jsonFilePath}`);
    return undefined;
  }
  const jsonContents = fs.readFileSync(jsonFilePath, "utf8");
  logger.debug(
    `Read pr-diff-range JSON file from ${jsonFilePath}:\n${jsonContents}`,
  );
  try {
    return JSON.parse(jsonContents) as DiffThunkRange[];
  } catch (e) {
    logger.warning(
      `Failed to parse diff ranges JSON file at ${jsonFilePath}: ${e}`,
    );
    return undefined;
  }
}

/**
 * Return the file line ranges that were added or modified in the pull request.
 *
 * @param branches The base and head branches of the pull request.
 * @param logger
 * @returns An array of tuples, where each tuple contains the relative path of a
 * file (relative to the repository root, as returned by the GitHub compare API),
 * the start line and the end line (both 1-based and inclusive) of an
 * added or modified range in that file. Returns `undefined` if the action was
 * not triggered by a pull request or if there was an error.
 */
export async function getPullRequestEditedDiffRanges(
  branches: PullRequestBranches,
  logger: Logger,
): Promise<DiffThunkRange[] | undefined> {
  const fileDiffs = await getFileDiffsWithBasehead(branches, logger);
  if (fileDiffs === undefined) {
    return undefined;
  }
  if (fileDiffs.length >= 300) {
    // The "compare two commits" API returns a maximum of 300 changed files. If
    // we see that many changed files, it is possible that there could be more,
    // with the rest being truncated. In this case, we should not attempt to
    // compute the diff ranges, as the result would be incomplete.
    logger.warning(
      `Cannot retrieve the full diff because there are too many ` +
        `(${fileDiffs.length}) changed files in the pull request.`,
    );
    return undefined;
  }
  const results: DiffThunkRange[] = [];
  for (const filediff of fileDiffs) {
    const diffRanges = getDiffRanges(filediff, logger);
    if (diffRanges === undefined) {
      return undefined;
    }
    results.push(...diffRanges);
  }
  return results;
}

/**
 * Compute and persist the diff ranges for a pull request. This fetches the
 * diff from the GitHub API and writes it to the diff ranges JSON file so that
 * CodeQL can use it for diff-informed analysis.
 *
 * @param branches The base and head branches of the pull request, as returned
 *   by `getDiffInformedAnalysisBranches`.
 * @param logger
 * @returns `true` if the diff ranges were successfully computed and persisted,
 *   otherwise `false`.
 */
export async function computeAndPersistDiffRanges(
  branches: PullRequestBranches,
  logger: Logger,
): Promise<boolean> {
  const ranges = await getPullRequestEditedDiffRanges(branches, logger);
  if (ranges === undefined) {
    return false;
  }
  writeDiffRangesJsonFile(logger, ranges);
  const distinctFiles = new Set(ranges.map((r) => r.path)).size;
  logger.info(
    `Persisted ${ranges.length} diff range(s) across ${distinctFiles} file(s).`,
  );
  return true;
}

async function getFileDiffsWithBasehead(
  branches: PullRequestBranches,
  logger: Logger,
): Promise<FileDiff[] | undefined> {
  // Check CODE_SCANNING_REPOSITORY first. If it is empty or not set, fall back
  // to GITHUB_REPOSITORY.
  const repositoryNwo = getRepositoryNwoFromEnv(
    "CODE_SCANNING_REPOSITORY",
    "GITHUB_REPOSITORY",
  );
  const basehead = `${branches.base}...${branches.head}`;
  try {
    const response = await getApiClient().rest.repos.compareCommitsWithBasehead(
      {
        owner: repositoryNwo.owner,
        repo: repositoryNwo.repo,
        basehead,
        per_page: 1,
      },
    );
    logger.debug(
      `Response from compareCommitsWithBasehead(${basehead}):` +
        `\n${JSON.stringify(response, null, 2)}`,
    );
    return response.data.files;
  } catch (error: any) {
    if (error.status) {
      logger.warning(`Error retrieving diff ${basehead}: ${error.message}`);
      logger.debug(
        `Error running compareCommitsWithBasehead(${basehead}):` +
          `\nRequest: ${JSON.stringify(error.request, null, 2)}` +
          `\nError Response: ${JSON.stringify(error.response, null, 2)}`,
      );
      return undefined;
    } else {
      throw error;
    }
  }
}

function getDiffRanges(
  fileDiff: FileDiff,
  logger: Logger,
): DiffThunkRange[] | undefined {
  if (fileDiff.patch === undefined) {
    if (fileDiff.changes === 0) {
      // There are situations where a changed file legitimately has no diff.
      // For example, the file may be a binary file, or that the file may have
      // been renamed with no changes to its contents. In these cases, the
      // file would be reported as having 0 changes, and we can return an empty
      // array to indicate no diff range in this file.
      return [];
    }
    // If a file is reported to have nonzero changes but no patch, that may be
    // due to the file diff being too large. In this case, we should fall back
    // to a special diff range that covers the entire file.
    return [
      {
        path: fileDiff.filename,
        startLine: 0,
        endLine: 0,
      },
    ];
  }

  // The 1-based file line number of the current line
  let currentLine = 0;
  // The 1-based file line number that starts the current range of added lines
  let additionRangeStartLine: number | undefined = undefined;
  const diffRanges: DiffThunkRange[] = [];

  const diffLines = fileDiff.patch.split("\n");
  // Adding a fake context line at the end ensures that the following loop will
  // always terminate the last range of added lines.
  diffLines.push(" ");

  for (const diffLine of diffLines) {
    if (diffLine.startsWith("-")) {
      // Ignore deletions completely -- we do not even want to consider them when
      // calculating consecutive ranges of added lines.
      continue;
    }
    if (diffLine.startsWith("+")) {
      if (additionRangeStartLine === undefined) {
        additionRangeStartLine = currentLine;
      }
      currentLine++;
      continue;
    }
    if (additionRangeStartLine !== undefined) {
      // Any line that does not start with a "+" or "-" terminates the current
      // range of added lines.
      diffRanges.push({
        path: fileDiff.filename,
        startLine: additionRangeStartLine,
        endLine: currentLine - 1,
      });
      additionRangeStartLine = undefined;
    }
    if (diffLine.startsWith("@@ ")) {
      // A new hunk header line resets the current line number.
      const match = diffLine.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
      if (match === null) {
        logger.warning(
          `Cannot parse diff hunk header for ${fileDiff.filename}: ${diffLine}`,
        );
        return undefined;
      }
      currentLine = parseInt(match[1], 10);
      continue;
    }
    if (diffLine.startsWith(" ")) {
      // An unchanged context line advances the current line number.
      currentLine++;
      continue;
    }
  }
  return diffRanges;
}

export const exportedForTesting = {
  getDiffRanges,
};
