# Copyright 2016 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

from recipe_engine import recipe_api

from PB.recipe_engine import result as result_pb2
from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2
from PB.go.chromium.org.luci.common.proto.findings import (
  findings as findings_pb,
)

# 8 minutes seems like a reasonable upper bound on presubmit timings.
# According to event mon data we have, it seems like anything longer than
# this is a bug, and should just instant fail.
_DEFAULT_TIMEOUT_S = 480


class PresubmitApi(recipe_api.RecipeApi):
  def __init__(self, properties, **kwargs):
    super(PresubmitApi, self).__init__(**kwargs)

    self._runhooks = properties.runhooks
    self._timeout_s = properties.timeout_s or _DEFAULT_TIMEOUT_S

  @property
  def presubmit_support_path(self):
    return self.repo_resource("presubmit_support.py")

  def __call__(self, *args, **kwargs):
    """Returns a presubmit step."""

    name = kwargs.pop("name", "presubmit")
    with self.m.depot_tools.on_path():
      cmd = ["vpython3", self.presubmit_support_path]
      cmd.extend(args)
      cmd.extend(["--json_output", self.m.json.output()])
      if self.m.resultdb.enabled:
        kwargs["wrapper"] = ("rdb", "stream", "--")
        if (
          "presubmit.resultdb_module"
          in self.m.buildbucket.build.input.experiments
        ):
          kwargs["wrapper"] = (
            "rdb",
            "stream",
            "-module-scheme",
            "flat",
            "-module-name",
            "//:presubmit",
            "--",
          )

      return self.m.step(name, cmd, **kwargs)

  @property
  def _relative_root(self):
    if self.m.tryserver.is_tryserver:
      return self.m.gclient.get_gerrit_patch_root().rstrip("/")
    else:
      return self.m.gclient.c.solutions[0].name.rstrip("/")

  def prepare(self, root_solution_revision=None):
    """Sets up a presubmit run.

    This includes:
      - setting up the checkout w/ bot_update
      - locally committing the applied patch
      - running hooks, if requested

    This expects the gclient configuration to already have been set.

    Args:
      root_solution_revision: revision of the root solution

    Returns:
      the StepResult from the bot_update step.
    """
    # Set up the root solution revision by either passing the revision
    # to this function or adding it to the input properties.
    root_solution_revision = root_solution_revision or self.m.properties.get(
      "root_solution_revision"
    )

    # Expect callers to have already set up their gclient configuration.

    bot_update_step = self.m.bot_update.ensure_checkout(
      timeout=3600,
      no_fetch_tags=True,
      root_solution_revision=root_solution_revision,
    )

    abs_root = self.m.context.cwd / self._relative_root
    if self.m.tryserver.is_tryserver:
      with self.m.context(cwd=abs_root):
        # TODO(unowned): Consider either:
        #  - extracting user name & email address from the issue, or
        #  - using a dedicated and clearly nonexistent name/email address
        step_result = self.m.git(
          "-c",
          "user.email=commit-bot@chromium.org",
          "-c",
          "user.name=The Commit Bot",
          "-c",
          "diff.ignoreSubmodules=all",
          "commit",
          "-a",
          "-m",
          "Committed patch",
          name="commit-git-patch",
          raise_on_failure=False,
          stdout=self.m.raw_io.output_text(
            "stdout", add_output_log="on_failure"
          ),
          infra_step=False,
        )
        if step_result.retcode:
          failure_md_lines = ["Failed to apply patch."]
          if step_result.stdout:
            failure_md_lines += step_result.stdout.splitlines() + [""]
            if "nothing to commit" in step_result.stdout:
              failure_md_lines.append(
                "Was an identical diff already submitted elsewhere?"
              )
          raise self.m.step.StepFailure("<br/>".join(failure_md_lines))

    if self._runhooks:
      with self.m.context(cwd=self.m.path.checkout_dir):
        self.m.gclient.runhooks()

    return bot_update_step

  def execute(self, bot_update_step, skip_owners=False, run_all=False):
    """Runs presubmit and sets summary markdown if applicable.

    Also uploads the presubmit results as findings if the results contain
    location data.

    Args:
      * bot_update_step: the StepResult from a previously executed bot_update step.
      * skip_owners: a boolean indicating whether Owners checks should be skipped.

    Returns:
      a RawResult object, suitable for being returned from RunSteps.
    """
    abs_root = self.m.context.cwd / self._relative_root
    got_revision_properties = self.m.bot_update.get_project_revision_properties(
      # Replace path.sep with '/', since most recipes are written assuming '/'
      # as the delimiter. This breaks on windows otherwise.
      self._relative_root.replace(self.m.path.sep, "/"),
      self.m.gclient.c,
    )
    upstream = bot_update_step.properties.get(got_revision_properties[0])

    presubmit_args = []
    if self.m.tryserver.is_tryserver:
      presubmit_args = [
        "--issue",
        self.m.tryserver.gerrit_change.change,
        "--patchset",
        self.m.tryserver.gerrit_change.patchset,
        "--gerrit_url",
        "https://%s" % self.m.tryserver.gerrit_change.host,
        "--gerrit_project",
        self.m.tryserver.gerrit_change.project,
        "--gerrit_branch",
        self.m.tryserver.gerrit_change_target_ref,
        "--gerrit_fetch",
      ]

    if run_all:
      presubmit_args.extend(["--all", "--no_diffs", "--verbose"])

    if self.m.cv.active and self.m.cv.run_mode == self.m.cv.DRY_RUN:
      presubmit_args.append("--dry_run")

    additionalArgs = ["--root", abs_root, "--commit"]

    if not run_all:
      additionalArgs.extend(
        [
          "--verbose",
          "--verbose",
        ]
      )

    additionalArgs.extend(
      [
        "--skip_canned",
        "CheckTreeIsOpen",
        "--upstream",
        upstream,  # '' if not in bot_update mode.
      ]
    )

    presubmit_args.extend(additionalArgs)

    if skip_owners:
      presubmit_args.extend(["--skip_canned", "CheckOwners"])

    raw_result = result_pb2.RawResult()
    presubmit_step = self(
      *presubmit_args,
      timeout=self._timeout_s,
      # ok_ret='any' causes all exceptions to be ignored in this step
      ok_ret="any",
    )
    if presubmit_step.exc_result.retcode != 0:
      presubmit_step.presentation.status = "FAILURE"

    # Set recipe result values and upload findings
    if step_json := presubmit_step.json.output:
      raw_result.summary_markdown = _createSummaryMarkdown(step_json)
      if self.m.tryserver.is_tryserver and self.m.resultdb.enabled:
        self.upload_findings_from_result(step_json)

    if presubmit_step.exc_result.retcode == 0:
      raw_result.status = common_pb2.SUCCESS
      return raw_result
    elif presubmit_step.exc_result.had_timeout:
      raw_result.status = common_pb2.FAILURE
      raw_result.summary_markdown += (
        "\n\nTimeout occurred during presubmit step."
      )
    elif presubmit_step.exc_result.retcode == 1:
      raw_result.status = common_pb2.FAILURE
      self.m.tryserver.set_test_failure_tryjob_result()
    else:
      raw_result.status = common_pb2.INFRA_FAILURE
      self.m.tryserver.set_invalid_test_results_tryjob_result()
    # Handle unexpected errors not caught by json output
    if raw_result.summary_markdown == "":
      raw_result.status = common_pb2.INFRA_FAILURE
      raw_result.summary_markdown = (
        "Something unexpected occurred"
        " while running presubmit checks."
        " Please [file a bug](https://issues.chromium.org"
        "/issues/new?component=1456211)"
      )
    return raw_result

  def upload_findings_from_result(self, result_json):
    """Parse code findings from presubmit results and then upload them.

    Args:
      result_json: the json result output from presubmit step.
    """
    findings = []
    base_finding = findings_pb.Finding(
      category="chromium_presubmit",
      location=findings_pb.Location(
        gerrit_change_ref=findings_pb.Location.GerritChangeReference(
          host=self.m.tryserver.gerrit_change.host,
          project=self.m.tryserver.gerrit_change.project,
          change=self.m.tryserver.gerrit_change.change,
          patchset=self.m.tryserver.gerrit_change.patchset,
        ),
      ),
    )
    for results, level in [
      (result_json.get("errors", []), findings_pb.Finding.SEVERITY_LEVEL_ERROR),
      (
        result_json.get("warnings", []),
        findings_pb.Finding.SEVERITY_LEVEL_WARNING,
      ),
      (
        result_json.get("notifications", []),
        findings_pb.Finding.SEVERITY_LEVEL_INFO,
      ),
    ]:
      for result in results:
        for loc in result.get("locations", []):
          f = findings_pb.Finding()
          f.CopyFrom(base_finding)
          f.message = result.get("message", "")
          f.severity_level = level
          f.location.file_path = loc["file_path"].replace(self.m.path.sep, "/")
          if loc.get("start_line", None):
            f.location.range.start_line = loc["start_line"]
            f.location.range.end_line = loc["end_line"]
            f.location.range.start_column = loc.get("start_col", 0)
            f.location.range.end_column = loc.get("end_col", 0)
          findings.append(f)

    if findings:
      self.m.findings.upload_findings(
        findings, step_name="upload presubmit results as findings"
      )


def _limitSize(message_list, char_limit=450):
  """Returns a list of strings within a certain character length.

  Args:
     * message_list (List[str]) - The message to truncate as a list
       of lines (without line endings).
  """
  hint = (
    "**The complete output can be"
    " found at the bottom of the presubmit stdout.**"
  )
  char_count = 0
  for index, message in enumerate(message_list):
    char_count += len(message)
    if char_count > char_limit:
      if index == 0:
        # Show at minimum part of the first error message
        first_message = message_list[index].splitlines()
        return ["\n".join(_limitSize(first_message))]
      total_errors = len(message_list)
      # If code is being cropped, the closing code tag will
      # get removed, so add it back before the hint.
      code_tag = "```"
      message_list[index - 1] = "\n".join((message_list[index - 1], code_tag))
      oversized_msg = (
        "\n**Error size > %d chars, there are %d more error(s) (%d total)**"
      ) % (char_limit, total_errors - index, total_errors)
      return message_list[:index] + [oversized_msg, hint]
  return message_list


def _createSummaryMarkdown(step_json):
  """Returns a string with data on errors, warnings, and notifications.

  Extracts the number of errors, warnings and notifications
  from the dictionary(step_json).

  Then it lists all the errors line by line.

  Args:
      * step_json = {
        'errors': [
          {
            'message': string,
            'long_text': string,
            'items: [string],
            'fatal': boolean
          }
        ],
        'notifications': [
          {
            'message': string,
            'long_text': string,
            'items: [string],
            'fatal': boolean
          }
        ],
        'warnings': [
          {
            'message': string,
            'long_text': string,
            'items: [string],
            'fatal': boolean
          }
        ]
      }
  """
  errors = step_json["errors"]
  warning_count = len(step_json["warnings"])
  notif_count = len(step_json["notifications"])
  description = (
    f"#### There are {len(errors)} error(s), {warning_count} warning(s), "
    f"and {notif_count} notifications(s)."
  )
  error_messages = []

  for error in errors:
    error_messages.append(
      "**ERROR**\n```\n%s\n%s\n```" % (error["message"], error["long_text"])
    )

  error_messages = _limitSize(error_messages)
  # Description is not counted in the total message size.
  # It is inserted afterward to ensure it is the first message seen.
  error_messages.insert(0, description)
  if warning_count or notif_count:
    error_messages.append(
      (
        "#### To see notifications and warnings,"
        " look at the stdout of the presubmit step."
      )
    )
  return "\n\n".join(error_messages)
