#!/usr/bin/env python3

import argparse
import math
import os.path
import sys
import subprocess


def fatal(msg):
    sys.stderr.write(f"[fatal] {msg}\n")
    sys.exit(1)


def verbose(options, *msg):
    if options.verbose:
        sys.stdout.write(f"[verbose] ")
        sys.stdout.write(*msg)
        sys.stdout.write('\n')


def METHOD_NAME(str):
    return "" if not str else str.splitlines()[0]


class Options:
    def __init__(self):
        ap = argparse.ArgumentParser(description="Show bugfixes differences between JBR and OpenJDK git repos",
                                     epilog="Example: %(prog)s  --jdk ./jdk11u/ --jbr ./JetBrainsRuntime/ --path src/hotspot --limit 200")
        ap.add_argument('--jdk', dest='jdkpath', help='path to OpenJDK git repo', required=True)
        ap.add_argument('--jbr', dest='jbrpath', help='path to JBR git repo', required=True)
        ap.add_argument('--path', dest='path', help='limit to changes in this path (relative to git root)')
        ap.add_argument('--limit', dest='limit', help='limit to this many log entries in --jdk repo', type=int, default=-1)
        ap.add_argument('-o', dest="output_dir", help="save patches to this directory (created if necessary)")
        ap.add_argument('-v', dest='verbose', help="verbose output", default=False, action='store_true')
        args = ap.parse_args()

        if not os.path.isdir(args.jdkpath):
            fatal(f"{args.jdkpath} not a directory")

        if not os.path.isdir(args.jbrpath):
            fatal(f"{args.jbrpath} not a directory")

        if not git_is_available():
            fatal("can't run git commands; make sure git is in PATH")

        self.jdkpath = args.jdkpath
        self.jbrpath = args.jbrpath
        self.path = args.path
        self.limit = args.limit
        self.output_dir = args.output_dir
        self.verbose = args.verbose


class GitRepo:
    def __init__(self, rootpath):
        self.rootpath = rootpath

    def run_git_cmd(self, git_args):
        args = ["git", "-C", self.rootpath]
        args.extend(git_args)
        # print(f"Runnig git cmd '{' '.join(args)}'")
        p = subprocess.run(args, capture_output=True, text=True)
        if p.returncode != 0:
            fatal(f"git returned non-zero code in {self.rootpath} ({METHOD_NAME(p.stderr)})")
        return p.stdout

    def save_git_cmd(self, fname, git_args):
        args = ["git", "-C", self.rootpath]
        args.extend(git_args)
        # print(f"Runnig git cmd '{' '.join(args)}'")
        with open(fname, "w") as stdout_file:
            p = subprocess.run(args, stdout=stdout_file)
            if p.returncode != 0:
                fatal(f"git returned non-zero code in {self.rootpath} ({METHOD_NAME(p.stderr)})")

    def current_branch(self):
        branch_name = self.run_git_cmd(["branch", "--show-current"]).strip()
        return branch_name

    def log(self, path=None, limit=None):
        cmds = ["log", "--no-decorate"]
        if limit:
            cmds.extend(["-n", str(limit)])
        if path:
            cmds.append(path)
        full_log = self.run_git_cmd(cmds)
        return full_log


class Commit:
    def __init__(self, lines):
        self.sha = lines[0].split()[1]
        self.message = ""
        self.bugid = None

        # Commit message starts after one blank line
        read_message = False
        for l in lines:
            if read_message:
                self.message += l + "\n"
            if not read_message and l == "":
                read_message = True

        if self.message and self.message != "" and ":" in self.message:
            maybe_bugid = self.message.split(":")[0].strip()
            if 10 >= len(maybe_bugid) >= 4:
                self.bugid = maybe_bugid


class History:
    def __init__(self, log):
        log_itr = iter(log.splitlines())
        self.commits = []
        commit_lines = []
        for line in log_itr:
            if line.startswith("commit ") and len(commit_lines) > 0:
                commit = Commit(commit_lines)
                self.commits.append(commit)
                commit_lines = []
            commit_lines.append(line)

        if len(commit_lines) > 0:
            commit = Commit(commit_lines)
            self.commits.append(commit)

    def contains(self, str):
        return any(str in commit.message for commit in self.commits)

    def size(self):
        return len(self.commits)


def print_explanation(options, jdk, jbr):
    verbose(options, f"Reading history from '{jdk.rootpath}' on branch '{jdk.current_branch()}'")
    if options.path:
        verbose(options, f"\t(only under '{options.path}')")
    verbose(options, f"Searching for same fixes in '{jbr.rootpath}' on branch '{jbr.current_branch()}'")


def git_is_available():
    p = None
    try:
        p = subprocess.run(["git", "--help"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    except:
        pass
    return p is not None and p.returncode == 0


def main():
    check_python_min_requirements()

    options = Options()

    jdk = GitRepo(options.jdkpath)
    jbr = GitRepo(options.jbrpath)

    print_explanation(options, jdk, jbr)

    commits_to_save = []
    try:
        jdk_log = jdk.log(options.path, options.limit)
        jdk_history = History(jdk_log)

        jbr_log = jbr.log(options.path)
        jbr_history = History(jbr_log)

        verbose(options, f"Read {jdk_history.size()} commits in JDK, {jbr_history.size()} in JBR")

        for c in jdk_history.commits:
            if c.bugid:
                verbose(options, f"Looking for bugfix for {c.bugid}")
                if not jbr_history.contains(c.bugid):
                    commits_to_save.append(c)
                    print(f"[note] Fix for {c.bugid} not found in JBR ({jbr.rootpath})")
                    print(f"    commit {c.sha}")
                    print(f"    {METHOD_NAME(c.message).strip()}")
    except KeyboardInterrupt:
        fatal("Interrupted")

    if len(commits_to_save) > 0 and options.output_dir:
        print()
        if not os.path.exists(options.output_dir):
            verbose(options, f"Creating output directory {options.output_dir}")
            os.makedirs(options.output_dir)
        nzeroes = len(str(len(commits_to_save)))
        for i, c in enumerate(reversed(commits_to_save)):
            fname = os.path.join(options.output_dir, f"{str(i).zfill(nzeroes)}-{c.bugid}.patch")
            print(f"[note] {c.bugid} saved as {fname}")
            fname = os.path.abspath(fname)
            jdk.save_git_cmd(fname, ["format-patch", "-1", c.sha, "--stdout"])

        script_fname = os.path.join(options.output_dir, "apply.sh")
        with open(script_fname, "w") as script_file:
            print(apply_script_code.format(os.path.abspath(jbr.rootpath), os.path.abspath(options.output_dir)),
                  file=script_file)
            print(f"[note] Execute 'bash {script_fname}' to apply patches to {jbr.rootpath}")


def check_python_min_requirements():
    if sys.version_info < (3, 6):
        fatal("Minimum version 3.6 is required to run this script")


apply_script_code = """
#!/bin/bash

GITROOT={0}
PATCHROOT={1}

cd $PATCHROOT || exit 1
PATCHES=$(find $PATCHROOT -name '*.patch' | sort -n)

for P in $PATCHES; do
    git -C $GITROOT am $P
    if [ $? != 0 ]; then
        mv "$P" "$P.failed"
        echo "[ERROR] Patch $P did not apply cleanly. Try applying it manually."
        echo "[NOTE]  Execute this script to apply the remaining patches."
        exit 1
    else
        mv "$P" "$P.done"
    fi
done

echo "[NOTE] Done applying patches; check $PATCHROOT for .patch and .patch.failed to see if all have been applied."
"""

if __name__ == '__main__':
    main()