#!/usr/bin/env python3
# Copyright 2022 The IREE Authors
#
# Licensed under the Apache License v2.0 with LLVM Exceptions.
# See https://llvm.org/LICENSE.txt for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
"""
Posts benchmark results to gist and comments on pull requests.

Requires the environment variables:

- GITHUB_TOKEN: token from GitHub action that has write access on issues. See
  https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
- COMMENT_BOT_USER: user name that posts the comment. Note this can be different
    from the user creates the gist.
- GIST_BOT_TOKEN: token that has write access to gist. Gist will be posted as
  the owner of the token. See
  https://docs.github.com/en/rest/overview/permissions-required-for-fine-grained-personal-access-tokens#gists
"""

import sys
import pathlib

# Add build_tools python dir to the search path.
sys.path.insert(0, str(pathlib.Path(__file__).parent.with_name("python")))

import argparse
import http.client
import json
import os
import requests
from typing import Any, Optional

from reporting import benchmark_comment

GITHUB_IREE_API_PREFIX = "https://api.github.com/repos/openxla/iree"
GITHUB_GIST_API = "https://api.github.com/gists"
GITHUB_API_VERSION = "2022-11-28"


class APIRequester(object):
    """REST API client that injects proper GitHub authentication headers."""

    def __init__(self, github_token: str):
        self._api_headers = {
            "Accept": "application/vnd.github+json",
            "Authorization": f"token {github_token}",
            "X-GitHub-Api-Version": GITHUB_API_VERSION,
        }
        self._session = requests.session()

    def get(self, endpoint: str, payload: Any = {}) -> requests.Response:
        return self._session.get(
            endpoint, data=json.dumps(payload), headers=self._api_headers
        )

    def post(self, endpoint: str, payload: Any = {}) -> requests.Response:
        return self._session.post(
            endpoint, data=json.dumps(payload), headers=self._api_headers
        )

    def patch(self, endpoint: str, payload: Any = {}) -> requests.Response:
        return self._session.patch(
            endpoint, data=json.dumps(payload), headers=self._api_headers
        )


class GithubClient(object):
    """Helper to call Github REST APIs."""

    def __init__(self, requester: APIRequester):
        self._requester = requester

    def post_to_gist(self, filename: str, content: str, verbose: bool = False) -> str:
        """Posts the given content to a new GitHub Gist and returns the URL to it."""

        response = self._requester.post(
            endpoint=GITHUB_GIST_API,
            payload={"public": True, "files": {filename: {"content": content}}},
        )
        if response.status_code != http.client.CREATED:
            raise RuntimeError(
                f"Failed to create on gist; error code: {response.status_code} - {response.text}"
            )

        response = response.json()
        if verbose:
            print(f"Gist posting response: {response}")

        if response["truncated"]:
            raise RuntimeError(f"Content is too large and was truncated")

        return response["html_url"]

    def get_previous_comment_on_pr(
        self,
        pr_number: int,
        comment_bot_user: str,
        comment_type_id: str,
        query_comment_per_page: int = 100,
        max_pages_to_search: int = 10,
        verbose: bool = False,
    ) -> Optional[int]:
        """Gets the previous comment's id from GitHub."""

        for page in range(1, max_pages_to_search + 1):
            response = self._requester.get(
                endpoint=f"{GITHUB_IREE_API_PREFIX}/issues/{pr_number}/comments",
                payload={
                    "per_page": query_comment_per_page,
                    "page": page,
                    "sort": "updated",
                    "direction": "desc",
                },
            )
            if response.status_code != http.client.OK:
                raise RuntimeError(
                    f"Failed to get PR comments from GitHub; error code: {response.status_code} - {response.text}"
                )

            comments = response.json()
            if verbose:
                print(f"Previous comment query response on page {page}: {comments}")

            # Find the most recently updated comment that matches.
            for comment in comments:
                if (
                    comment["user"]["login"] == comment_bot_user
                    and comment_type_id in comment["body"]
                ):
                    return comment["id"]

            if len(comments) < query_comment_per_page:
                break

        return None

    def update_comment_on_pr(self, comment_id: int, content: str):
        """Updates the content of the given comment id."""

        response = self._requester.patch(
            endpoint=f"{GITHUB_IREE_API_PREFIX}/issues/comments/{comment_id}",
            payload={"body": content},
        )
        if response.status_code != http.client.OK:
            raise RuntimeError(
                f"Failed to comment on GitHub; error code: {response.status_code} - {response.text}"
            )

    def create_comment_on_pr(self, pr_number: int, content: str):
        """Posts the given content as comments to the current pull request."""

        response = self._requester.post(
            endpoint=f"{GITHUB_IREE_API_PREFIX}/issues/{pr_number}/comments",
            payload={"body": content},
        )
        if response.status_code != http.client.CREATED:
            raise RuntimeError(
                f"Failed to comment on GitHub; error code: {response.status_code} - {response.text}"
            )

    def get_pull_request_head_commit(self, pr_number: int) -> str:
        """Get pull request head commit SHA."""

        response = self._requester.get(
            endpoint=f"{GITHUB_IREE_API_PREFIX}/pulls/{pr_number}"
        )
        if response.status_code != http.client.OK:
            raise RuntimeError(
                f"Failed to fetch the pull request: {pr_number}; "
                f"error code: {response.status_code} - {response.text}"
            )

        return response.json()["head"]["sha"]


def _parse_arguments():
    parser = argparse.ArgumentParser()
    parser.add_argument("comment_json", type=pathlib.Path)
    parser.add_argument("--verbose", action="store_true")
    verification_parser = parser.add_mutually_exclusive_group(required=True)
    verification_parser.add_argument("--github_event_json", type=pathlib.Path)
    # Temporary option for buildkite pipeline.
    verification_parser.add_argument("--no_verify_pr", action="store_true")
    return parser.parse_args()


def main(args: argparse.Namespace):
    github_token = os.environ.get("GITHUB_TOKEN")
    if github_token is None:
        raise ValueError("GITHUB_TOKEN must be set.")

    comment_bot_user = os.environ.get("COMMENT_BOT_USER")
    if comment_bot_user is None:
        raise ValueError("COMMENT_BOT_USER must be set.")

    gist_bot_token = os.environ.get("GIST_BOT_TOKEN")
    if gist_bot_token is None:
        raise ValueError("GIST_BOT_TOKEN must be set.")

    comment_data = benchmark_comment.CommentData(
        **json.loads(args.comment_json.read_text())
    )
    # Sanitize the pr number to make sure it is an integer.
    pr_number = int(comment_data.unverified_pr_number)

    pr_client = GithubClient(requester=APIRequester(github_token=github_token))
    if args.github_event_json is None:
        github_event = None
    else:
        github_event = json.loads(args.github_event_json.read_text())
        workflow_run_sha = github_event["workflow_run"]["head_sha"]
        pr_head_sha = pr_client.get_pull_request_head_commit(pr_number=pr_number)
        # We can't get the trusted PR number of a workflow run from GitHub API. So we
        # take the untrusted PR number from presubmit workflow and verify if the PR's
        # current head SHA matches the commit SHA in the workflow run. It assumes
        # that to generate the malicious comment data, attacker must modify the code
        # and has a new commit SHA. So if the PR head commit matches the workflow
        # run with attacker's commit, either the PR is created by the attacker or
        # other's PR has the malicious commit. In both cases posting malicious
        # comment is acceptable.
        #
        # Note that the collision of a target SHA1 is possible but GitHub has some
        # protections (https://github.blog/2017-03-20-sha-1-collision-detection-on-github-com/).
        # The assumption also only holds if files in GCS can't be overwritten (so the
        # comment data can't be modified without changing the code).
        # The check will also fail if the PR author pushes the new commit after the
        # workflow is triggered. But pushing the new commit means to cancel the
        # current CI run including the benchmarking. So it will unlikely fail for
        # that reason.
        if workflow_run_sha != pr_head_sha:
            raise ValueError(
                f"Workflow run SHA: {workflow_run_sha} does not match "
                f"the head SHA: {pr_head_sha} of the pull request: {pr_number}."
            )

    gist_client = GithubClient(requester=APIRequester(github_token=gist_bot_token))
    gist_url = gist_client.post_to_gist(
        filename=f"iree-full-benchmark-results-{pr_number}.md",
        content=comment_data.full_md,
        verbose=args.verbose,
    )

    previous_comment_id = pr_client.get_previous_comment_on_pr(
        pr_number=pr_number,
        comment_bot_user=comment_bot_user,
        comment_type_id=comment_data.type_id,
        verbose=args.verbose,
    )

    abbr_md = comment_data.abbr_md.replace(
        benchmark_comment.GIST_LINK_PLACEHORDER, gist_url
    )
    if github_event is not None:
        abbr_md += (
            f'\n\n[Source Workflow Run]({github_event["workflow_run"]["html_url"]})'
        )
    if previous_comment_id is not None:
        pr_client.update_comment_on_pr(comment_id=previous_comment_id, content=abbr_md)
    else:
        pr_client.create_comment_on_pr(pr_number=pr_number, content=abbr_md)


if __name__ == "__main__":
    main(_parse_arguments())
