#!/usr/bin/env python3

import os
import sys
import json
import subprocess
import glob
import argparse
import collections
import itertools
import traceback

LINE_PORCELAIN_RECORD_LINE_COUNT = 12

class Author(object):
    ''' Helper class to manipulate author name and email separately.
    Behaves as a string
    '''
    def __init__(self, name, email):
        self.name = name
        self.email = email

    def __hash__(self):
        return hash(self.__str__())

    def __eq__(self, other):
        return self.name == other.name and self.email == other.email

    def __lt__(self, other):
        return self.name < other.name

    def __gt__(self, other):
        return self.name > other.name

    def __repr__(self):
        return "Author({},{})".format(self.name, self.email)
    def __str__(self):
        return "{} {}".format(self.name, self.email)

def parse_blame_record(line_list):
# Example blame lines:
#  c477125a7c93c1e7ea77a051a99b8ded8ef56143 2 2 1
#  author Tobias Olausson
#  author-mail <tobias.olausson@pelagicore.com>
#  author-time 1484322672
#  author-tz +0100
#  committer Therese Nordqvist
#  committer-mail <therese.nordqvist@pelagicore.com>
#  committer-time 1484329264
#  committer-tz +0100
#  summary Updates copyright headers to 2016-2017
#  filename libsoftwarecontainer/src/gateway/network/networkgateway.cpp
#           * Copyright (C) 2016-2017 Pelagicore AB
    if len(line_list) != LINE_PORCELAIN_RECORD_LINE_COUNT:
        raise Exception("Invalid parse input: wrong line count of line blame record: {}".format(len(line_list)))
    rec = {}
    rec['line'] = int(line_list[0].split(' ')[2])
    # Store author as tuple (name, email)
    rec['author'] = Author(
        line_list[1].replace('author ', '').strip("\n"),
        line_list[2].replace('author-mail ', '').strip("\n")
        )
    return rec

def _run_process(cmd_line):
    p = subprocess.run(cmd_line, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, encoding="utf-8")
    return p

def call_git_blame(filename):
    # git blame --line-porcelain HEAD {filename} | grep -v "^previous"
    # suppress grep return code if the file is empty
    args = ["git", "blame", "--line-porcelain", "HEAD", "\"{}\"".format(filename), "|", "grep", "-v", "\"^previous\"", "||", "true"]
    cmd_line = " ".join(args)
    p = _run_process(cmd_line)
    p.check_returncode()
    return p.stdout


def parse_git_blame_output(filename):
    blame_objects = []
    blame_line_list = []
    cnt = 0
    proc_output = call_git_blame(filename)
    for line in proc_output.splitlines():
        blame_line_list.append(line)
        cnt += 1

        if cnt == LINE_PORCELAIN_RECORD_LINE_COUNT:
            blame_obj = parse_blame_record(blame_line_list)
            blame_obj['filename'] = filename
            blame_objects.append(blame_obj)
            blame_line_list = []
            cnt = 0

    return blame_objects

def run_format_tool(filename, tool_name, blame_lines=None, config_file=None):
    if tool_name == "clang":
        call_clang_format_tool(filename, blame_lines, config_file)
    elif tool_name == "cmake":
        call_cmake_format_tool(filename, blame_lines, config_file)
    elif tool_name == "uncrustify":
        call_uncrustify_tool(filename, blame_lines, config_file)
    else:
        raise Exception("Tool {} is not supported".format(tool_name))

def call_cmake_format_tool(filename, blame_records=None, config_file=None):
    args = ["cmake-format", "-i"]

    if config_file is not None:
        args += ["-c", config_file]

    if blame_records is not None:
        raise Exception("cmake-format: formatting by lines is not supported (TODO)")

    args.append("\"{}\"".format(filename))
    cmd_line = " ".join(args)
    p = _run_process(cmd_line)
    p.check_returncode()

def call_clang_format_tool(filename, blame_records=None, config_file=None):
    args = ["clang-format", "-i", "--verbose"]

    if config_file is not None:
        raise Exception("clang-format: config file option is not supported (TODO)")

    if blame_records is not None:
        args += list(["--lines={}:{}".format(x['line'], x['end-line']) for x in blame_records])

    args.append("\"{}\"".format(filename))
    cmd_line = " ".join(args)
    p = _run_process(cmd_line)
    p.check_returncode()

def call_uncrustify_tool(filename, blame_records=None, config_file=None):
    args = ["uncrustify"]

    if config_file is not None:
        args += ["-c", config_file]

    if blame_records is not None:
        raise Exception("uncrustify: formatting by lines is not supported (TODO)")

    args += ["-f", "\"{}\"".format(filename), "-o", "\"{}\"".format(filename)]

    cmd_line = " ".join(args)
    p = _run_process(cmd_line)
    # FIXME: uncrustify returns 1 if file is reformatted
    # p.check_returncode()

def call_git_status_filter_changed():
    # git status --porcelain=2 --untracked-files=no | awk '{print $9}'
    args = ["git", "status", "--porcelain=2", "--untracked-files=no", "|", "awk", "'{print $9}'"]
    cmd_line = " ".join(args)
    p = _run_process(cmd_line)
    p.check_returncode()
    return p.stdout

def call_git_commit(file_list, author, commit_message):
    args = ["git", "commit", "--author=\"{} {}\"".format(author.name, author.email), "-m", "\"{}\"".format(commit_message)]
    args += file_list
    cmd_line = " ".join(args)
    p = _run_process(cmd_line)
    p.check_returncode()

def generate_blame_intervals(blame_objects):
    '''
        Merge adjacent file lines and generate blame intervals
    '''
    new_objs = []
    cur_obj = None
    for obj in blame_objects:
        if not cur_obj:
            cur_obj = obj
            cur_obj['end-line'] = cur_obj['line']
            continue

        if obj['author'] == cur_obj['author'] and obj['line'] == cur_obj['end-line'] + 1:
            cur_obj['end-line'] = obj['line']
        else:
            new_objs.append(cur_obj)
            cur_obj = obj
            cur_obj['end-line'] = cur_obj['line']

    new_objs.append(cur_obj)

    return new_objs

def get_changed_files():
    return call_git_status_filter_changed().splitlines()

def is_file_in_git(file):
    args = ["git", "ls-files", "--error-unmatch", file]
    cmd_line = " ".join(args)
    p = _run_process(cmd_line)
    return p.returncode == 0

def collect_authors(files):
    authors_list = []

    for filename in files:
        rec_list = parse_git_blame_output(filename)
        authors = [x['author'] for x in rec_list]
        # Insert authors into unique authors list
        # TODO: optimize the loop
        for a in authors:
            if a not in authors_list:
                authors_list.append(a)
    return authors_list

def get_requested_files(files):
    filtered_filenames = []
    for file_glob in files:
        filtered_filenames += glob.glob(file_glob)
    filtered_filenames = [x for x in filtered_filenames if is_file_in_git(x)]
    return filtered_filenames

def run_line_based_algorithm(args):
    # Pass 0. Prepare
    print("- SMART GIT FORMAT - : Line based algorithm")

    print("[0] Prepare")

    filtered_filenames = get_requested_files(args.files)
    print("[0] Collected filenames: {}".format(filtered_filenames))

    # Pass 1. Collect all authors
    print ("[1] Collect all authors")
    authors_list = collect_authors(filtered_filenames)

    print("[1] RESULT: unique list of authors")
    for a in authors_list:
        print(a)

    # Pass 2. For each author, collect intervals in files and apply them
    # NOTE: after each commit, we need to call 'blame' again
    #       (clang-format may format the whole function if function title is formatted)
    # TODO: implement replacement list of authors and prioritization of commits
    #       due to following cases: author is out of project, author name is a collision with other name,
    print("[2] For each author, make changes and commit")

    for author in authors_list:
        # NOTE: we have to rescan the whole file list again after git commit,
        # since side effects of formatting tools may result in changing lines of other authors

        changed_file_list = get_changed_files()
        if len(changed_file_list) != 0:
            print("[2]: Author: {}, cannot collect changes before previous changes are committed. Consistency error.")
            return 1

        for filename in filtered_filenames:
            print("[2] Author: {}, process {}".format(author, filename))
            # Filter output from git blame
            rec_list = parse_git_blame_output(filename)
            # Generate interval-based output from git blame to make less calls to format tool
            blame_rec = generate_blame_intervals(rec_list)
            # Filter records by author
            blame_rec = [x for x in blame_rec if x['author'] == author]

            run_format_tool(filename, args.tool, blame_rec, args.tool_config)

        # After all files are formatted, collect changed files and commit them
        changed_file_list = get_changed_files()
        if len(changed_file_list) == 0:
            print("[2] Author: {}, changes in files might have been overwritten by ither authors. Consider prioritization if you want to make this author more important".format(author, filename))
            continue

        # Commit all changed files
        commit_message = "style: Reformat the code \n\nAuthored-By: {}".format(author)
        call_git_commit(changed_file_list, author, commit_message)

    print("[3] Final reformat, use first author to reformat all sources")
    author = authors_list[0]

    for filename in filtered_filenames:
        print("[3] Author: {}, process {}".format(author, filename))
        run_format_tool(filename, args.tool, blame_lines=None, config_file=args.tool_config)

        changed_file_list = get_changed_files()
        if len(changed_file_list) == 0:
            print("[3] Author: {}, no changes".format(author, filename))
            continue

        commit_message = "style: Apply formatting tool to the files (line-based) \n\nAuthored-By: {}".format(author)
        call_git_commit([filename], author, commit_message)

def run_file_based_algorithm(args):
    print("- SMART GIT FORMAT - : File based algorithm")
    filtered_filenames = get_requested_files(args.files)

    authors_list = collect_authors(filtered_filenames)

    authors_and_files = []
    for filename in filtered_filenames:
            # Filter output from git blame
            rec_list = parse_git_blame_output(filename)

            if len(rec_list) == 0:
                print("NOTE: file {} blame is empty, skipping".format(filename))
                continue

            author_list = [x['author'] for x in rec_list]
            c = collections.Counter(author_list)
            best_author = c.most_common(1)[0][0]

            authors_and_files.append((best_author, filename))
            # TODO: calculate count of authors and sort them

    # itertools.groupby requires list sorted by the grouped key
    authors_and_files.sort(key=lambda x:x[0])

    authors_grouped = []
    # authors_and_files contain the list of files which should be formatted by author.
    # Now, group them and run formatting
    for key, group in itertools.groupby(authors_and_files, key=lambda x: x[0]):
        # for i,j in g:
            # print(k,i,j)
        authors_grouped.append((key,[x[1] for x in group]))

    for item in authors_grouped:
        author = item[0]
        files = item[1]

        for filename in files:
            print("Author: {}, process {}".format(author, filename))
            run_format_tool(filename, args.tool, blame_lines=None, config_file=args.tool_config)

        changed_file_list = get_changed_files()

        if len(changed_file_list) == 0:
            print("Author: {}, no changes".format(author, files))
            continue

        # Commit all changed files
        commit_message = "style: Apply formatting tool to the files \n\nAuthored-By: {}".format(author)
        call_git_commit(changed_file_list, author, commit_message)

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--only-authors', action='store_true',
                        help='Collect info about authors and exit')
    parser.add_argument("--tool", default="clang", choices=["clang", "cmake", "uncrustify"], help="Formatter to use")
    parser.add_argument("--tool-config", type=str)
    parser.add_argument("--algo", default="lines", choices=["lines", "files"], help="Stats calculation algorithm to use")
    parser.add_argument('files', nargs='*', help="Files to format")

    args = parser.parse_args()

    if len(get_changed_files()) != 0:
        print("ERROR: cannot work with dirty repository. Please save your changes and proceed with clean state")
        return 1

    if (args.tool_config is not None) and (not os.path.exists(args.tool_config)):
        print("ERROR: tool configuration file does not exist")
        return 1

    if args.only_authors:
        files = get_requested_files(args.files)
        authors_list = collect_authors(files)
        for author in authors_list:
            print("{}".format(author))
        return 0

    ret = 0
    if args.algo == "lines":
        ret = run_line_based_algorithm(args)
    elif args.algo == "files":
        ret = run_file_based_algorithm(args)
    else:
        raise Exception("Invalid algorithm {}".format(args.algo))

    return ret

if __name__ == "__main__":
    ret = -1

    try:
        ret = main()
    except Exception as e:
        import traceback
        print("ERROR: exception {}".format(e))
        traceback.print_exc(file=sys.stderr)

    exit(ret)
