#!/usr/bin/python3

from datetime import datetime
from tempfile import TemporaryDirectory
import argparse
import os
import re
import shutil
import subprocess
import sys

from common import shell, backports, git_range, git_color, rev_parse, \
                   KeyType, GitColor, GitConfig, ChangesetStore


#
# Classes
#

class Command(object):
    """A command in a cherry plan."""
    REVIEW      = '    '
    APPLIED     = 'noop'
    PICK        = 'pick'
    DROP        = 'drop'

class UnknownCommandError(Exception):
    pass

class Commit(object):
    """A single commit in a cherry plan."""

    def __init__(self, command, hash, subject, abbrev):
        self.command = command
        self.hash = hash
        self.short = hash[:abbrev]
        self.subject = subject

    def _str(self, short):
        """Return this commit as a text line."""
        cmd = self.command + ' ' if not short else ''
        return '{}{} {}'.format(cmd, self.short, self.subject)

    def __str__(self):
        return self._str(False)

class Changeset(object):
    """A group of commits forming a logical changeset."""

    def __init__(self, data, commits, show_labels=True):
        self.title = 'PR #{}: {}'.format(data['number'], data['title'])
        self.labels = data['labels']
        self.url = data['url']
        self.commits = commits
        self.show_labels = show_labels

    def automark(self):
        """Mark all commits in this changeset automatically."""
        if self.labels & AUTOPICK:
            self.pick()
        elif self.labels & AUTODROP:
            self.drop()

    def pick(self):
        """Pick all commits in this changeset."""
        for commit in self.commits:
            commit.command = Command.PICK

    def drop(self):
        """Drop all commits in this changeset."""
        for commit in self.commits:
            commit.command = Command.DROP

    def __str__(self):
        """Return this changeset as a group of text lines."""
        if self.show_labels:
            if self.labels:
                labels = ' '.join(sorted(list(self.labels)))
            else:
                labels = 'none'
            labels = ' (labels: {})'.format(labels)
        else:
            labels = ''
        commits = [str(commit) for commit in self.commits]
        return '# {}{}\n{}'.format(self.title, labels, '\n'.join(commits))

class Plan(object):
    """A cherry plan."""

    def __init__(self, branch, changeset_size, show_labels, automark,
                 add_timestamp, status_limit, refresh, quiet):
        # Branch to pull commits from
        self.branch = branch
        self.changesets = ChangesetStore()
        self.changeset_size = changeset_size
        self.show_labels = show_labels
        self.automark = automark
        self.add_timestamp = add_timestamp
        self.status_limit = status_limit
        self.refresh = refresh
        self.quiet = quiet

        self.HEAD = 'HEAD'
        # Length of an abbreviated commit hash
        self.abbrev = len(rev_parse(self.HEAD))

        # Patterns for matching a commit line and the footer
        self.re_commit = re.compile(
            '^([^#]...) (.{{{abbrev}}}) (.+)$'.format(abbrev=self.abbrev))
        self.re_footer = re.compile(
            '^# Rebase .{{{abbrev}}}\\.\\.'.format(abbrev=self.abbrev))

        # All commits in this plan
        self.log = []
        # All (unexpanded) text lines in this plan
        self.lines = []
        # Loaded filename
        self.file = None
        # Position of the scissors line within the log
        self.scissors = 0

    @property
    def head(self):
        """The hash of the last commit in this plan (or HEAD if none)."""
        if self.log:
            return self.log[-1].hash
        return self.HEAD

    @property
    def last(self):
        """The last line in this plan (or the empty string if none)."""
        return self.lines[-1] if self.lines else ''

    @property
    def slice(self):
        """All commits below the scissors line."""
        return self.log[self.scissors:]

    @property
    def picks(self):
        """All picked commits."""
        return [c for c in self.slice if c.command == Command.PICK]

    @property
    def drops(self):
        """All dropped commits."""
        return [c for c in self.slice if c.command == Command.DROP]

    @property
    def noops(self):
        """All applied commits."""
        return [c for c in self.slice if c.command == Command.APPLIED]

    @property
    def todo(self):
        """All unreviewed commits."""
        return [c for c in self.slice if c.command == Command.REVIEW]

    def _log(self):
        """Return a commit log of new commits on the branch."""
        out = shell('git rev-list --reverse --no-commit-header '
                    '--pretty="format:%H %s" ' +
                    git_range(self.head, self.branch))
        if out:
            out = out.split('\n')
        return out

    def _list(self, which):
        if which == 'picks':
            prefix = 'Picked'
            commits = self.picks
            color = GitColor.STATUS_ADDED
        elif which == 'drops':
            prefix = 'Dropped'
            commits = self.drops
            color = GitColor.STATUS_CHANGED
        elif which == 'noops':
            prefix = 'Applied'
            commits = self.noops
            color = None
        elif which == 'todo':
            prefix = 'Unreviewed'
            commits = self.todo
            color = GitColor.STATUS_UNTRACKED

        count = len(commits)
        if count:
            suffix = ' ({})'.format(count)
        else:
            suffix = ''
        print('{} commits{}:'.format(prefix, suffix))
        if commits:
            if count > self.status_limit and self.status_limit > 0:
                print('  (...)')
            for c in commits[-self.status_limit:]:
                print('  ' + git_color(c._str(True), color))
        else:
            print('  (no commits)')
        print()

    def load(self, file):
        """Populate the plan from a file on disk."""

        self.log = []
        self.lines = []
        self.file = file
        # Number of commits read
        count = 0

        with open(file) as f:
            for line in f:
                line = line.strip('\n')

                # Mark the position of the scissors line (if any)
                if not self.scissors and line == SCISSORS:
                    self.scissors = count

                # Stop at the footer
                m = re.match(self.re_footer, line)
                if m is not None:
                    break

                # Skip if not a commit
                m = re.match(self.re_commit, line)
                if m is None:
                    self.lines.append(line)
                    continue

                # Parse a commit
                command, hash, subject = m.groups()
                if command not in COMMANDS:
                    raise UnknownCommandError(line)
                commit = Commit(COMMANDS[command], hash, subject, self.abbrev)

                self.log.append(commit)
                self.lines.append(commit)

                count += 1

    def save(self, file=None):
        """Dump the plan to a file on disk."""
        if file is None:
            file = self.file
        with open(file, 'w') as f:
            f.write(str(self))

    def update(self, show_stats=False):
        """Mark the applied commits and update the footer."""

        applied = backports('{}..'.format(self.branch), self.abbrev)
        changed = 0

        for commit in self.log:
            if commit.short not in applied:
                continue
            if commit.command != Command.APPLIED:
                commit.command = Command.APPLIED
                changed += 1

        if show_stats and changed:
            print('Updated {} commits'.format(changed))

    def pull(self, show_stats=False):
        """Append new commits on the branch to the plan."""

        # Obtain a commit log from git
        lines = self._log()
        if not lines:
            return

        msg = 'Pulled {} commits'.format(len(lines))

        # Populate our log
        commits = [Commit(Command.REVIEW, *line.split(' ', 1), self.abbrev)
                   for line in lines]
        self.log.extend(commits)

        # Prepend a timestamp (if requested)
        if self.add_timestamp and self.lines:
            now = datetime.now()
            self.lines.append('# Pulled ' + now.strftime('%Y-%m-%d %H:%M:%S'))

        # Populate lines with commits only
        if self.changeset_size == 0:
            self.lines.extend(commits)
            if show_stats:
                print(msg)
            return

        # Populate lines with changesets too
        self.changesets.load([c.hash for c in commits],
                             self.refresh, not self.quiet)
        for commit in commits:
            try:
                s = self.changesets[commit.hash]
            except KeyError:
                self.lines.append(commit)
                continue

            s = Changeset(s, [], self.show_labels)
            if isinstance(self.last, Changeset) and \
                    self.last.title == s.title:
                s = self.last
            else:
                self.lines.append(s)

            s.commits.append(commit)

        # Automark changesets
        if self.automark:
            for line in self.lines:
                if isinstance(line, Changeset):
                    line.automark()

        if show_stats:
            print(msg)

    def cut(self, where, delete=False):
        """Insert a scissors line below the given commit."""

        # Remove the existing scissors line (if any)
        if SCISSORS in self.lines:
            self.lines.remove(SCISSORS)

        if delete:
            return

        # Locate the last reviewed commit if where is not given
        if where is None:
            last = None
            for commit in self.log:
                if commit.command == Command.REVIEW:
                    if last is None:
                        return
                    where = last.hash
                    break
                last = commit
            else:
                return

        # Insert a scissors line and mark all commits above it as dropped
        for num, line in enumerate(self.lines):
            if not isinstance(line, Commit):
                continue
            if line.hash == where:
                num += 1
                self.lines[num:num] = [SCISSORS]
                break
            if line.command == Command.REVIEW:
                line.command = Command.DROP

    def dump(self, cut=False):
        """Dump the plan into a string and return it."""

        lines = []
        start = not (cut and self.scissors)

        # Expand all lines
        for line in self.lines:
            if not start:
                if line == SCISSORS:
                    start = True
                else:
                    continue
            if isinstance(line, Changeset):
                # Collapse the changeset if smaller than the threshold
                if line.commits and len(line.commits) < self.changeset_size:
                    lines += [str(commit) for commit in line.commits]
                else:
                    # Pad the changeset with empty lines
                    if lines and lines[-1]:
                        lines += ['']
                    lines += [str(line), '']
            else:
                lines += [str(line)]

        # Strip the trailing newline (if any)
        if lines and not lines[-1]:
            del lines[-1]

        # Add the footer
        lines += [FOOTER.format(rev_parse(self.HEAD), rev_parse(self.head),
                  rev_parse(self.HEAD), len(self.log))]

        return '\n'.join(lines)

    def __str__(self):
        return self.dump()

    def apply(self):
        """Apply the plan to the current branch."""
        picks = [c.hash for c in self.picks]
        if not picks:
            return
        picks = ' '.join(picks)
        cmd = 'echo "{}" | xargs git cherry-pick -x'.format(picks)
        return shell(cmd, False).returncode

    def status(self):
        """Show the plan's status."""

        if self.file is not None:
            print('On plan {}'.format(os.path.basename(self.file)))

        count = len(self._log())
        if count:
            status = "behind '{}' by {} commits".format(self.branch, count)
        else:
            status = "up to date with '{}'".format(self.branch)
        print("Your plan is {}.".format(status))
        print()

        self._list('picks')
        self._list('drops')
        self._list('noops')
        self._list('todo')

    def list(self, markers, short=False):
        """List commit hashes, filtered by given markers (if any)."""
        markers = ['    ' if m == '-' else m for m in markers]
        for commit in self.log:
            if markers and commit.command not in markers:
                continue
            print(commit.hash if short else rev_parse(commit.hash, False))


#
# Helper functions
#

def ensure_exists(file):
    if not os.path.exists(file):
        sys.stderr.write('File {} does not exist.\n'.format(file))
        return True
    return False

def ensure_not_exists(file):
    if os.path.exists(file):
        sys.stderr.write('File {} exists.\n'.format(file))
        return True
    return False

#
# CLI verbs
#

def make(plan, args):
    if ensure_not_exists(args.file):
        return 1
    plan.pull()
    plan.update()
    plan.save(args.file)
    print(args.file)
    return 0

def edit(plan, args):
    if ensure_exists(args.file):
        return 1
    shell('$EDITOR ' + args.file, False)
    return 0

def pull(plan, args):
    if ensure_exists(args.file):
        return 1
    plan.load(args.file)
    plan.pull(True)
    plan.save()
    return 0

def cut(plan, args):
    if ensure_exists(args.file):
        return 1
    plan.load(args.file)
    plan.cut(args.where, args.delete)
    plan.save()
    return 0

def dump(plan, args):
    if ensure_exists(args.file):
        return 1
    plan.load(args.file)
    print(plan.dump(True), end='')
    return 0

def check(plan, args):
    with TemporaryDirectory() as dirname:
        file = os.path.abspath(args.file)
        script = '\
            git clone . {dirname} && \
            git -C {dirname} cherry-plan -f {file} apply \
        '.format(dirname=dirname, file=file)
        out = shell(script, True, False)

    if out.returncode:
        m = re.findall('^error: could not apply (.+)\\.\\.\\. (.+)$',
                       out.stderr, re.MULTILINE)
        if not m:
            m = re.findall('^The previous cherry-pick is now empty',
                           out.stderr, re.MULTILINE)
            if not m:
                sys.stderr.write(out.stderr)
                return 3
            if not args.short:
                print(git_color('Plan does not apply cleanly due to an '
                                'empty commit.', GitColor.STATUS_CHANGED))
            return 2

        hash, subject = m[0]
        if args.short:
            print('{} {}'.format(hash, subject))
        else:
            print(git_color('Plan does not apply cleanly due to '
                            'conflicting commit:\n{} {}'.format(hash, subject),
                            GitColor.STATUS_CHANGED))
        return 1

    if not args.short:
        print(git_color('Plan applies cleanly.', GitColor.STATUS_ADDED))
    return 0

def apply(plan, args):
    if ensure_exists(args.file):
        return 1
    plan.load(args.file)
    return plan.apply()

def update(plan, args):
    if ensure_exists(args.file):
        return 1
    plan.load(args.file)
    plan.update(True)
    plan.save()
    return 0

def status(plan, args):
    if ensure_exists(args.file):
        return 1
    plan.load(args.file)
    plan.status()
    return 0

def commits(plan, args):
    if ensure_exists(args.file):
        return 1
    plan.load(args.file)
    plan.list(args.marker, args.short)
    return 0

#
# Configuration
#

ABOUT = '''
Manage persistent rebase-like todo lists ("plans") to cherry-pick commits.

Useful for crafting larger cherry-pick batches and collaborating on them, e.g.
when preparing a stable release from a development branch.

This tool produces an equivalent of

    git rebase -i HEAD BRANCH

with the following differences:

    * standalone file (can be shared, tracked in git, etc.)
    * incremental (always applies to HEAD, no rewriting of history)
    * includes already applied commits for more context ("noop" marker)
    * supports the empty marker (to indicate unreviewed commits)
    * groups commits by logical changesets (e.g. pull requests)

git configuration (cherryPlan section):
  directory             save plan files here if FILE is unspecified
                        (default: $PWD)
  backportPatterns      patterns to extract original commit hash from
                        backported commit's message (multi-valued key)
  automark.pick         automatically pick changesets with one of these labels
  automark.drop         automatically drop changesets with one of these labels
'''

FOOTER = '''
# Rebase {}..{} onto {} ({} commands)
#
# Commands:
# pick <commit> = use commit
# drop <commit> = remove commit, not suitable
# noop <commit> = remove commit, already applied
#      <commit> = remove commit, not reviewed yet
#
# These lines MUST NOT be re-ordered; they are executed from top to bottom.
#
# vim:syntax=gitrebase
'''

# Map command markers in a plan file to the constants
COMMANDS = {
    '    ': Command.REVIEW,
    'noop': Command.APPLIED,
    'pick': Command.PICK,
    'drop': Command.DROP,
}

# Load git configuration
CONFIG = GitConfig('cherryPlan')
DIRECTORY = CONFIG.get('directory', default='.')
FILE = '{}/{}.plan'.format(DIRECTORY, shell('git rev-parse --abbrev-ref HEAD'))
AUTOPICK = CONFIG.get('automark.pick', KeyType.SET, set())
AUTODROP = CONFIG.get('automark.drop', KeyType.SET, set())
SCISSORS = '# ------------------------ >8 ------------------------'

#
# Argument parsing
#

parser = argparse.ArgumentParser(description=ABOUT,
    formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('-b', '--branch', type=str, default='master',
    help='branch to cherry-pick from (default: master)')
parser.add_argument('-f', '--file', type=str, default=FILE,
    help='plan file to use (default: {})'.format(FILE))
parser.add_argument('-s', '--changeset-size', type=int, default=2,
    help='only group by changesets with at least this many commits, '
         '0 disables grouping (default: 2)')
parser.add_argument('-r', '--refresh', action='store_true',
    help='always fetch changeset data from server (do not use cache)')
parser.add_argument('-q', '--quiet', action='store_true',
    help='do not print progress messages on stderr')
parser.add_argument('-t', '--timestamp', dest='add_timestamp',
    action='store_true', help='insert timestamp above newly pulled commits')
parser.add_argument('--no-automark', dest='automark',
    action='store_false', help='do not pick or drop commits automatically')
parser.add_argument('--no-label', dest='show_labels',
    action='store_false', help='do not show labels in changesets')
subparsers = parser.add_subparsers()

parser_make = subparsers.add_parser(
    'make', help='create plan for current branch with commits on BRANCH')
parser_make.set_defaults(verb=make)

parser_edit = subparsers.add_parser(
    'edit', help='edit plan with $EDITOR')
parser_edit.set_defaults(verb=edit)

parser_pull = subparsers.add_parser(
    'pull', help='append new commits on BRANCH to plan')
parser_pull.set_defaults(verb=pull)

parser_cut = subparsers.add_parser(
    'cut', help='insert scissors line below given commit and mark all commits '
                'above the line as dropped')
parser_cut.set_defaults(verb=cut)
parser_cut.add_argument('where', nargs='?',
    help='hash of commit to cut below '
         '(or first unreviewed commit if not given)')
parser_cut.add_argument('-d', '--delete', action='store_true',
    help='delete existing scissors line (if any)')

parser_dump = subparsers.add_parser('dump',
    help='print plan to stdout, with everything above scissors line cut off')
parser_dump.set_defaults(verb=dump)

parser_list = subparsers.add_parser(
    'list', help='list commit hashes in plan')
parser_list.set_defaults(verb=commits)
parser_list.add_argument('marker', type=str, nargs='*',
    help='markers to filter by (use - for empty)')
parser_list.add_argument('-s', '--short', action='store_true',
    help='print abbreviated hashes')

parser_check = subparsers.add_parser(
    'check', help='check if plan applies cleanly to current branch')
parser_check.set_defaults(verb=check)
parser_check.add_argument('-s', '--short', action='store_true',
    help='brief mode, only print conflicting commit (if any)')

parser_apply = subparsers.add_parser(
    'apply', help='apply plan to current branch')
parser_apply.set_defaults(verb=apply)

parser_update = subparsers.add_parser(
    'update', help='update plan with current branch (mark applied commits and '
                   'update footer) ')
parser_update.set_defaults(verb=update)

parser_status = subparsers.add_parser(
    'status', help='show current status of plan')
parser_status.set_defaults(verb=status)
parser_status.add_argument('-l', '--limit', type=int, default=20,
    help='maximum number of commits to show in each section, '
         '0 means no limit (default: 20)')

args = parser.parse_args()
if hasattr(args, 'verb'):
    limit = args.limit if hasattr(args, 'limit') else None
    plan = Plan(args.branch, args.changeset_size, args.show_labels,
                args.automark, args.add_timestamp, limit, args.refresh,
                args.quiet)
    code = args.verb(plan, args)
    sys.exit(code)
else:
    parser.print_usage()
    sys.exit(2)
