#!/usr/bin/python3
# coding=utf-8
#
# The Qubes OS Project, https://www.qubes-os.org/
#
# Copyright (C) 2015 Marek Marczykowski-Górecki <marmarek@invisiblethingslab.com>
# Copyright (C) 2022 Frédéric Pierret (fepitre) <frederic@invisiblethingslab.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#

import logging
import os
import re
import subprocess
import sys
from argparse import ArgumentParser
from pathlib import Path

from github import Github, GithubException

from qubesbuilder.distribution import QubesDistribution

log = logging.getLogger("notify-issues")

github_issues_repo = "QubesOS/qubes-issues"
github_api_prefix = "https://api.github.com"
github_repo_prefix = "QubesOS/qubes-"
github_baseurl = "https://github.com"

fixes_re = re.compile(
    r"(fixes|closes)( (https://github.com/[^ ]+/|" r"QubesOS/Qubes-issues#)[0-9]+)",
    re.IGNORECASE,
)
issue_re = re.compile(r"QubesOS/Qubes-issues(#|/issues/)[0-9]+", re.IGNORECASE)
cleanup_re = re.compile(r"[^ ]+[#/]")
release_name_re = re.compile("r[0-9.]+")
number_re = re.compile('"number": *([0-9]+)')


class NotifyIssueError(Exception):
    pass


class NotifyIssueCli:
    def __init__(
        self,
        token: str,
        release_name: str,
        src_dir: Path,
        package_name: str,
        dist: QubesDistribution,
        message_templates_dir: Path,
        github_report_repo_name: str,
        min_age_days: int,
    ):
        self.token = token
        self.release_name = release_name
        self.src_dir = src_dir
        self.package_name = package_name
        self.dist = dist
        self.message_templates_dir = message_templates_dir
        self.github_report_repo_name = github_report_repo_name
        self.min_age_days = min_age_days
        self.gi = Github(self.token)

    def get_current_commit(self):
        git_proc = subprocess.Popen(
            ["git", "-C", str(self.src_dir), "log", "-n", "1", "--pretty=format:%H"],
            stdout=subprocess.PIPE,
        )
        (current_commit, _) = git_proc.communicate()
        current_commit = current_commit.decode().strip()
        return current_commit

    def get_package_changes(self, git_url, commit_sha, previous_commit_sha=None):
        """Returns a tuple of:
        - current version
        - previous version
        - git short log formatted with GitHub links
        - referenced GitHub issues, in GitHub syntax
        """
        git_proc = subprocess.Popen(
            ["git", "-C", str(self.src_dir), "tag", "--list", "--points-at=HEAD", "v*"],
            stdout=subprocess.PIPE,
        )
        (version_tags, _) = git_proc.communicate()
        versions = version_tags.decode().splitlines()
        if not versions:
            raise ValueError("No version tags found")
        version = versions[0]

        # get previous version
        if previous_commit_sha:
            git_proc = subprocess.Popen(
                [
                    "git",
                    "-C",
                    str(self.src_dir),
                    "describe",
                    "--match",
                    "v*",
                    "--exact-match",
                    previous_commit_sha,
                ],
                stdout=subprocess.PIPE,
            )
            (version_tags, _) = git_proc.communicate()
            if not version_tags:
                # if no tag there, point at the commit directly
                version_tags = previous_commit_sha.encode()
        else:
            git_proc = subprocess.Popen(
                [
                    "git",
                    "-C",
                    str(self.src_dir),
                    "describe",
                    "--match",
                    "v*",
                    "--abbrev=0",
                    commit_sha + "~",
                ],
                stdout=subprocess.PIPE,
            )
            (version_tags, _) = git_proc.communicate()
        if not version_tags:
            # if no previous version tag, check from (some) root commit
            git_proc = subprocess.Popen(
                [
                    "git",
                    "-C",
                    str(self.src_dir),
                    "rev-list",
                    "--max-parents=0",
                    commit_sha + "~",
                ],
                stdout=subprocess.PIPE,
            )
            (version_tags, _) = git_proc.communicate()

        if not version_tags:
            # still nothing - looks there is only one commit - no history
            return version, version, "", ""
        previous_version = version_tags.decode().splitlines()[0]

        git_log_proc = subprocess.Popen(
            [
                "git",
                "-C",
                str(self.src_dir),
                "log",
                "{}..{}".format(previous_version, version),
            ],
            stdout=subprocess.PIPE,
        )

        (git_log, _) = git_log_proc.communicate()
        git_log = git_log.decode()
        referenced_issues = []
        for line in git_log.splitlines():
            match = issue_re.search(line)
            if match:
                issues_string = match.group(0)
                issues_numbers = [
                    int(cleanup_re.sub("", s)) for s in issues_string.split()
                ]
                referenced_issues.extend(issues_numbers)

        referenced_issues_txt = "\n".join(
            "QubesOS/qubes-issues#{}".format(x) for x in set(referenced_issues)
        )

        github_full_repo_name = "/".join(git_url.split("/")[-2:])

        git_log_proc = subprocess.Popen(
            [
                "git",
                "-C",
                str(self.src_dir),
                "log",
                "--pretty=format:{}@%h %s".format(github_full_repo_name),
                "{}..{}".format(previous_version, version),
            ],
            stdout=subprocess.PIPE,
        )
        (shortlog, _) = git_log_proc.communicate()
        shortlog = shortlog.decode()

        return version, previous_version, shortlog, referenced_issues_txt

    def search_or_create_issue(
        self, release, component, version, create=True, message_template_kwargs=None
    ):
        try:
            github_repo = self.gi.get_repo(self.github_report_repo_name)
        except GithubException as e:
            raise NotifyIssueError(str(e)) from e

        issue_title = "{component} {version} ({release})".format(
            component=component, version=version, release=release
        )
        issue_no = None
        for issue in github_repo.get_issues():
            if issue.title == issue_title:
                issue_no = issue.number
                break

        # if nothing, create new issue
        if issue_no is None:
            # don't create if requested so
            if not create:
                return None

            if component.startswith("qubes-template"):
                message_template_path = (
                    self.message_templates_dir / "message-build-report-template"
                )
            elif component.startswith("iso"):
                message_template_path = (
                    self.message_templates_dir / "message-build-report-iso"
                )
            else:
                message_template_path = (
                    self.message_templates_dir / "message-build-report"
                )

            if not message_template_path.exists():
                log.warning(f"Cannot find template message")
                return None

            with open(message_template_path) as f:
                message_template = f.read()

            message = (
                message_template.replace("@COMPONENT@", component)
                .replace("@RELEASE_NAME@", release)
                .replace("@VERSION@", version)
                .replace("@MIN_AGE_DAYS@", str(self.min_age_days))
            )

            if message_template_kwargs is not None:
                for key, value in message_template_kwargs.items():
                    message = message.replace(key, value)

            try:
                issue = github_repo.create_issue(title=issue_title, body=message)
                issue_no = issue.number
            except GithubException as e:
                log.warning(f"Failed to create issue: {str(e)}")

        return issue_no

    def comment_issue(
        self,
        issue_no,
        message,
        add_labels,
        delete_labels,
        github_repo=github_issues_repo,
    ):

        try:
            github_repo = self.gi.get_repo(github_repo)
            issue = github_repo.get_issue(issue_no)
        except GithubException as e:
            raise NotifyIssueError(str(e)) from e

        if message:
            try:
                issue.create_comment(body=message)
            except GithubException as e:
                log.warning(f"Failed to create comment on {issue_no}: {str(e)}")

        for label in delete_labels:
            try:
                issue.remove_from_labels(label)
            except GithubException as e:
                log.warning(
                    f"Failed to delete {label} label from issue {issue_no}: {str(e)}"
                )

        for label in add_labels:
            try:
                issue.add_to_labels(label)
            except GithubException as e:
                log.warning(
                    f"Failed to add {label} label to issue {issue_no}: {str(e)}"
                )

    def notify_closed_issues(
        self, repo_type, current_commit, previous_commit, add_labels, delete_labels
    ):
        message = f"message-{repo_type}-{self.dist.package_set}"
        if (self.message_templates_dir / f"{message}-{self.dist.name}").exists():
            message_template_path = (
                self.message_templates_dir / f"{message}-{self.dist.name}"
            )
        elif (self.message_templates_dir / f"{message}-{self.dist.fullname}").exists():
            message_template_path = (
                self.message_templates_dir / f"{message}-{self.dist.fullname}"
            )
        else:
            log.warning("Cannot find message template not adding comments")
            message_template_path = None

        git_log_proc = subprocess.Popen(
            [
                "git",
                "-C",
                str(self.src_dir),
                "log",
                "{}..{}".format(previous_commit, current_commit),
            ],
            stdout=subprocess.PIPE,
        )
        (git_log, _) = git_log_proc.communicate()
        closed_issues = []
        for line in git_log.decode().splitlines():
            match = fixes_re.search(line)
            if match:
                issues_string = match.group(0)
                issues_numbers = [
                    int(cleanup_re.sub("", s)) for s in issues_string.split()[1:]
                ]
                closed_issues.extend(issues_numbers)

        closed_issues = set(closed_issues)

        git_log_proc = subprocess.Popen(
            [
                "git",
                "-C",
                str(self.src_dir),
                "log",
                "--pretty=format:{}-{}@%h %s".format(
                    github_repo_prefix, self.src_dir.name
                ),
                "{}..{}".format(previous_commit, current_commit),
            ],
            stdout=subprocess.PIPE,
        )
        (shortlog, _) = git_log_proc.communicate()
        shortlog = shortlog.decode()

        git_url_var = "GIT_URL_" + self.src_dir.name.replace("-", "_")
        if git_url_var in os.environ:
            git_url = os.environ[git_url_var]
        else:
            git_url = "{base}/{prefix}{repo}".format(
                base=github_baseurl,
                prefix=github_repo_prefix,
                repo=self.src_dir.name,
            )
        git_log_url = "{git_url}/compare/{prev_commit}...{curr_commit}".format(
            git_url=git_url, prev_commit=previous_commit, curr_commit=current_commit
        )

        component = self.src_dir.name

        for issue in closed_issues:
            log.info(f"Adding a comment to issue #{issue}")
            if message_template_path:
                message = (
                    open(message_template_path, "r")
                    .read()
                    .replace("@DIST@", self.dist.name)
                    .replace("@PACKAGE_SET@", self.dist.package_set)
                    .replace("@PACKAGE_NAME@", self.package_name)
                    .replace("@COMPONENT@", component)
                    .replace("@REPOSITORY@", repo_type)
                    .replace("@RELEASE_NAME@", self.release_name)
                    .replace("@GIT_LOG@", shortlog)
                    .replace("@GIT_LOG_URL@", git_log_url)
                )
            else:
                message = None

            self.comment_issue(issue, message, add_labels, delete_labels)

    def notify_build_report(
        self,
        dist_label,
        repo_type,
        add_labels,
        delete_labels,
        commit_sha,
        previous_stable_commit_sha,
        build_status=None,
        build_log=None,
        additional_info=None,
        repository_url=None,
    ):

        if self.package_name.startswith("iso"):
            base_message = f"ISO for {self.release_name}"
            if repository_url:
                upload_suffix_message = f"[testing]({repository_url}) repository"
            else:
                upload_suffix_message = f"testing repository"
        elif self.package_name.startswith("qubes-template"):
            base_message = f"Template {self.package_name.replace('qubes-template-', '')}"
            upload_suffix_message = f"{repo_type} repository"
        else:
            base_message = f"Package for {dist_label}"
            upload_suffix_message = f"{repo_type} repository"

        if build_status == "building":
            report_message = None
        elif build_status == "built":
            if build_log:
                report_message = f"{base_message} was built ([build log]({build_log}))."
            else:
                report_message = (
                    f"{base_message} was built."
                )
            if additional_info:
                report_message = f"{report_message.rstrip('.')} ({additional_info})."
        elif build_status == "uploaded":
            report_message = (
                f"{base_message} was uploaded to {upload_suffix_message}."
            )
            if additional_info:
                report_message = f"{report_message.rstrip('.')} ({additional_info})."
        elif build_status == "failed":
            if build_log:
                report_message = (
                    f"{base_message} failed to build ([build log]({build_log}))."
                )
            else:
                report_message = f"{base_message} failed to build."
            if additional_info:
                report_message = f"{report_message.rstrip('.')} ({additional_info})."
        else:
            raise NotifyIssueError(f"Unexpected build status '{build_status}'")

        component = self.src_dir.name

        git_url_var = "GIT_URL_" + component.replace("-", "_")
        if git_url_var in os.environ:
            git_url = os.environ[git_url_var]
        else:
            git_url = "{base}/{prefix}{repo}".format(
                base=github_baseurl, prefix=github_repo_prefix, repo=component
            )

        if self.package_name.startswith("qubes-template"):
            # qubes-template-fedora-25-4.0.0-201710170053
            version = "-".join(self.package_name.split("-")[-2:])
            component = "-".join(self.package_name.split("-")[:-2])
            template_name = component.replace("qubes-template-", "")
            message_kwargs = {
                # "@COMMIT_SHA@": commit_sha,
                # "@GIT_URL@": git_url,
                "@TEMPLATE_NAME@": template_name,
                "@DIST@": os.getenv("DIST_ORIG_ALIAS", "(dist)"),
            }
        elif self.package_name.startswith("iso"):
            parsed_package_name = self.package_name.split("-")
            version = parsed_package_name[-1]
            component = "iso"
            message_kwargs = {"@ISO_VERSION@": version}
        else:
            (
                version,
                previous_version,
                shortlog,
                referenced_issues_txt,
            ) = self.get_package_changes(
                git_url,
                commit_sha,
                previous_commit_sha=previous_stable_commit_sha,
            )

            git_log_url = "{git_url}/compare/{prev_commit}...{curr_commit}".format(
                git_url=git_url, prev_commit=previous_version, curr_commit=version
            )

            message_kwargs = {
                "@COMMIT_SHA@": commit_sha,
                "@GIT_URL@": git_url,
                "@GIT_LOG@": shortlog,
                "@GIT_LOG_URL@": git_log_url,
                "@ISSUES@": referenced_issues_txt,
            }

        report_issue_no = self.search_or_create_issue(
            self.release_name,
            component,
            version=version,
            create=True,
            message_template_kwargs=message_kwargs,
        )
        if report_issue_no:
            self.comment_issue(
                report_issue_no,
                report_message,
                add_labels,
                delete_labels,
                github_repo=self.github_report_repo_name,
            )


def add_required_args_to_parser(parser):
    parser.add_argument("release_name", help="Release name (e.g. r4.2)")
    parser.add_argument("src_dir", help="Component sources path")
    parser.add_argument("package_name", help="Binary package name")
    parser.add_argument(
        "distribution", help="Qubes OS Distribution name (e.g. host-fc32)"
    )


def main():
    epilog = "When state_file doesn't exists, no notify is sent, but the current state is recorded"

    parser = ArgumentParser(epilog=epilog)
    parser.add_argument("--auth-token", help="Github authentication token (OAuth2)")
    parser.add_argument("--build-log", help="Build log name in build-logs repository")
    parser.add_argument("--message-templates-dir", help="Message templates directory")
    parser.add_argument("--github-report-repo-name", help="Github repository to report")
    parser.add_argument("--additional-info", help="Add additional info on comment")
    parser.add_argument(
        "--days",
        action="store",
        type=int,
        default=5,
        help="ensure package at least this time in testing (default: %(default)d)",
    )
    subparsers = parser.add_subparsers(help="command")

    # Upload status parser
    upload_parser = subparsers.add_parser("upload")
    upload_parser.set_defaults(command="upload")
    # Common args
    add_required_args_to_parser(upload_parser)
    # Extra args
    upload_parser.add_argument(
        "repo_type",
        help="Repository type",
        choices=[
            "current",
            "current-testing",
            "security-testing",
            "unstable",
            "templates-itl",
            "templates-itl-testing",
            "templates-community",
            "templates-community-testing",
            "iso-testing",
        ],
    )
    upload_parser.add_argument(
        "state_file", help="File to store internal state (previous commit id)"
    )
    upload_parser.add_argument(
        "stable_state_file",
        help="File to store internal state (previous commit id of a stable aka current package)",
    )
    upload_parser.add_argument(
        "--repository-url",
        help="URL where is uploaded the package, template or ISO.",
        default=None,
    )

    # Build status
    build_parser = subparsers.add_parser("build")
    build_parser.set_defaults(command="build")
    # Common args
    add_required_args_to_parser(build_parser)
    build_parser.add_argument(
        "status", help="Build status", choices=["failed", "building", "built", "uploaded"]
    )

    args = parser.parse_args()

    token = args.auth_token or os.environ.get("GITHUB_API_KEY")
    github_report_repo_name = args.github_report_repo_name or os.environ.get(
        "GITHUB_BUILD_REPORT_REPO"
    )

    if not token:
        log.error("Please provide GITHUB_API_KEY either as CLI arg or environ.")
        return 1

    if not release_name_re.match(args.release_name):
        log.error(f"Ignoring release {args.release_name}")
        return 1

    if not github_report_repo_name:
        log.error(
            "Please provide GITHUB_BUILD_REPORT_REPO either as CLI arg or environ."
        )
        return 1

    message_templates_dir = (
        Path(args.message_templates_dir).resolve()
        if args.message_templates_dir
        else Path("templates").resolve()
    )
    if not message_templates_dir.exists():
        log.error("Cannot find message templates directory.")
        return 1

    dist = QubesDistribution(args.distribution)

    cli = NotifyIssueCli(
        token=token,
        release_name=args.release_name,
        src_dir=Path(args.src_dir).resolve(),
        package_name=args.package_name,
        dist=dist,
        github_report_repo_name=github_report_repo_name,
        message_templates_dir=message_templates_dir,
        min_age_days=args.days,
    )

    if dist.package_set == "host":
        dist_label = "host"
    else:
        dist_label = dist.distribution

    add_labels = []
    delete_labels = []
    repo_type = None
    if args.package_name.startswith("iso") or args.package_name.startswith("qubes-template"):
        prefix_label = f"{args.release_name}"
    else:
        prefix_label = f"{args.release_name}-{dist_label}"

    if args.command == "upload":
        build_status = "uploaded"
        repo_type = args.repo_type
        repository_url = args.repository_url
        delete_labels = [
            f"{prefix_label}-failed",
            f"{prefix_label}-building",
        ]
        if args.repo_type == "current":
            repo_type = "stable"
            delete_labels += [
                f"{prefix_label}-cur-test",
                f"{prefix_label}-sec-test",
            ]
            add_labels = [f"{prefix_label}-stable"]
        elif args.repo_type == "current-testing":
            add_labels = [f"{prefix_label}-cur-test"]
        elif args.repo_type == "security-testing":
            add_labels = [f"{prefix_label}-sec-test"]
        elif args.repo_type == "templates-itl":
            delete_labels += [f"{args.release_name}-testing"]
            add_labels = [f"{args.release_name}-stable"]
        elif args.repo_type == "templates-itl-testing":
            add_labels += [f"{args.release_name}-testing"]
        elif args.repo_type == "templates-community":
            delete_labels += [f"{args.release_name}-testing", "iso"]
            add_labels = [f"{args.release_name}-stable"]
        elif args.repo_type in ("templates-community-testing", "iso-testing"):
            add_labels = [f"{args.release_name}-testing"]
        else:
            log.warning(f"Ignoring {args.repo_type}")
            return
    else:
        repository_url = None
        build_status = args.status
        if build_status == "failed":
            add_labels = [f"{prefix_label}-failed"]
            delete_labels = [f"{prefix_label}-building"]
        elif build_status == "building":
            add_labels = [f"{prefix_label}-building"]
            # we ensure that we don't keep those labels in case of previous failures
            delete_labels = [f"{prefix_label}-failed"]
        elif build_status == "built":
            delete_labels = [f"{prefix_label}-failed", f"{prefix_label}-building"]

    current_commit = cli.get_current_commit()
    previous_stable_commit = None

    try:
        if args.command == "upload":
            if not os.path.exists(args.state_file):
                log.warning(
                    f"{args.state_file} does not exist, initializing with the current state"
                )
                previous_commit = None
            else:
                with open(args.state_file, "r") as f:
                    previous_commit = f.readline().strip()

            if previous_commit is not None:
                if repo_type in ["stable", "current-testing"]:
                    cli.notify_closed_issues(
                        repo_type,
                        current_commit,
                        previous_commit,
                        add_labels,
                        delete_labels,
                    )

            with open(args.state_file, "w") as f:
                f.write(current_commit)

            if os.path.exists(args.stable_state_file):
                with open(args.stable_state_file, "r") as f:
                    previous_stable_commit = f.readline().strip()

        cli.notify_build_report(
            dist_label=dist_label,
            repo_type=repo_type,
            add_labels=add_labels,
            delete_labels=delete_labels,
            commit_sha=current_commit,
            previous_stable_commit_sha=previous_stable_commit,
            build_status=build_status,
            build_log=args.build_log,
            additional_info=args.additional_info,
            repository_url=repository_url,
        )
    except NotifyIssueError as e:
        log.error(str(e))
        return 1


if __name__ == "__main__":
    sys.exit(main())
