import argparse
import logging
import os
from pygit2 import Commit
import shutil
import subprocess
import sys
import tempfile
import textwrap
from gitubuntu.git_repository import GitUbuntuRepository, git_dep14_tag
from gitubuntu.run import decode_binary, run
from gitubuntu.source_information import GitUbuntuSourceInformation
from gitubuntu.versioning import version_compare

class TagException(Exception):
    pass

class ReconstructException(Exception):
    pass

def parse_args(subparsers=None, base_subparsers=None):
    kwargs = dict(description='Given a %(prog)s import\'d tree, assist with an Ubuntu merge',
                  formatter_class=argparse.RawTextHelpFormatter,
                  epilog='Creates old/ubuntu, old/debian and new/debian '
                         'tags for the specified Ubuntu merge.\n'
                         'Breaks the specified Ubuntu merge '
                         'into a sequence of non-git-merge-commits '
                         'suitable for rebasing.\n'
                         'Working tree must be clean.'
                 )
    if base_subparsers:
        kwargs['parents'] = base_subparsers
    if subparsers:
        parser = subparsers.add_parser('merge', **kwargs)
        parser.set_defaults(func=cli_main)
    else:
        parser = argparse.ArgumentParser(**kwargs)

    parser.add_argument('subsubcommand',
                        help='start - Breakup Ubuntu delta into individual commits\n'
                             'finish - Construct d/changelog suitable for uploading. '
                             '`%(prog)s merge start` must have been run first.',
                        metavar='start|finish',
                        choices=['start', 'finish'])

    width, _ = shutil.get_terminal_size()
    subhelp_width = width - 30
    subhelp_text = '\n'.join(textwrap.wrap(
        'A reference to a commit whose corresponding ' +
        'version is to be merged.',
        subhelp_width
                                          )
                            )
    parser.add_argument('commitish',
                        type=str,
                        help=subhelp_text
                       )
    subhelp_text = '\n'.join(textwrap.wrap(
        'A reference to a commit whose corresponding ' +
        'version to prepare to merge with. If not ' +
        'specified, debian/sid is used. ' +
        'If <onto> does not already exist as a ' +
        'local branch, it will be created and track ' +
        'pkg/<onto>.',
        subhelp_width
                                          )
                            )
    parser.add_argument('onto',
                        help=subhelp_text,
                        type=str,
                        default='debian/sid',
                        nargs='?'
                       )
    parser.add_argument('--bug',
                        help='Launchpad bug to close with this merge.',
                        type=str)
    parser.add_argument('--release', type=str,
                        help='Ubuntu release to target with merge. If not '
                        'specified, the current development release from '
                        'Launchpad is used.')
    parser.add_argument('-d', '--directory', type=str,
                        help='Use git repository at specified location.',
                        default=os.path.abspath(os.getcwd())
                       )
    parser.add_argument('-f', '--force',
                        help='Overwrite existing tags and branches, where '
                             'applicable.',
                        action='store_true'
                       )
    parser.add_argument('--tag-only',
                        help='When rebasing a previous merge, only '
                             'tag old/ubuntu, old/debian and new/debian.',
                        action='store_true'
                       )
    if not subparsers:
        return parser.parse_args()
    return 'merge - %s' % kwargs['description']

def cli_main(args):
    return main(
        directory=args.directory,
        commitish=args.commitish,
        onto=args.onto,
        force=args.force,
        tag_only=args.tag_only,
        bug=args.bug,
        release=args.release,
        subcommand=args.subsubcommand,
    )


def do_tag(repo, tag_prefix, commitish, merge_base_commit_hash, onto, force):
    errors = False
    try:
        repo.tag('%sold/ubuntu' % tag_prefix, commitish, force)
    except:
        errors = True
    try:
        repo.tag('%sold/debian' % tag_prefix, merge_base_commit_hash, force)
    except:
        errors = True
    try:
        repo.tag('%snew/debian' % tag_prefix, onto, force)
    except:
        errors = True
    if errors:
        raise TagException('Failed to tag commits.')

def do_reconstruct(repo, tag_prefix, commitish, merge_base_commit_hash, force):
    versions=()
    repo.checkout_commitish(merge_base_commit_hash)
    stdout, _ = repo.git_run(
        [
            'rev-list',
            '--ancestry-path',
            '--reverse',
            '%s..%s' % (merge_base_commit_hash, commitish),
        ],
        decode=False,
    )
    for commit in stdout.split(b'\n'):
        commit = decode_binary(commit).strip()
        # empty newline
        if len(commit) == 0:
            continue
        obj = repo.get_commitish(commit)
        args = [
            'cherry-pick',
            '--allow-empty',
            '--keep-redundant-commits',
        ]
        if len(obj.peel(Commit).parents) > 1:
            args += ['-m', '2']
        args += [str(obj.id)]
        repo.git_run(args)
    try:
        repo.git_run(['diff', '--exit-code', commitish])
    except:
        logging.error(
            "Resulting cleaned-up commit is not "
            "source-identical to %s",
            commitish
        )
        raise ReconstructException("Failed to reconstruct commit.")

    old_head_version, _ = repo.get_changelog_versions_from_treeish(commitish)
    repo.tag(
        '%sreconstruct/%s' % (tag_prefix, git_dep14_tag(old_head_version)),
        str(repo.get_commitish('HEAD').id),
        force,
    )

def do_start(repo, tag_prefix, commitish, merge_base_commit_hash, onto, force, tag_only):
    try:
        do_tag(repo, tag_prefix, commitish, merge_base_commit_hash, onto, force)
        if not tag_only:
            do_reconstruct(repo, tag_prefix, commitish, merge_base_commit_hash, force)
        return 0
    except (TagException, ReconstructException):
        return 1

def do_merge_changelogs(repo, commitish, merge_base_commit_hash, onto):
    # save merge_base_commit_hash:debian/changelog to tmp file
    old_debian_changelog = repo.extract_file_from_treeish(
        merge_base_commit_hash,
        'debian/changelog',
    )
    old_ubuntu_changelog = repo.extract_file_from_treeish(
        commitish,
        'debian/changelog',
    )
    new_debian_changelog = repo.extract_file_from_treeish(
        onto,
        'debian/changelog',
    )
    run(
        [
            'dpkg-mergechangelogs',
            old_debian_changelog.name,
            old_ubuntu_changelog.name,
            new_debian_changelog.name,
            'debian/changelog',
        ]
    )
    # handle conflicts
    repo.git_run(['commit', '-m', 'merge-changelogs', 'debian/changelog'])
    old_debian_changelog.close()
    old_ubuntu_changelog.close()
    new_debian_changelog.close()

def do_reconstruct_changelog(repo, onto, release, bug):
    # XXX: extract release from launchpad, add flag
    distribution = release
    if not distribution:
        # Don't need most arguments here as we're only grabbing some
        # data from launchpad about the active series
        ubuntu_source_information = GitUbuntuSourceInformation(
           'ubuntu',
            None
        )
        distribution = ubuntu_source_information.active_series_name_list[0]
    run(
        [
            'dch',
            '--force-distribution',
            '--distribution',
            distribution,
            'PLACEHOLDER',
        ]
    )
    with tempfile.NamedTemporaryFile(mode='w+t', delete=False) as fp:
        with open('debian/changelog', 'r+t') as dch:
            for line in dch:
                if 'PLACEHOLDER' in line:
                    fp.write('  * Merge with Debian unstable')
                    if bug:
                        fp.write(' (LP: #%s)' % bug)
                    fp.write('. Remaining changes:\n')
                    # merge-changelogs is HEAD
                    stdout, _ = repo.git_run(
                        ['rev-list', '--reverse', '%s..HEAD^' % onto,]
                    )
                    for rev in stdout.splitlines():
                        out, _ = repo.git_run(
                            ['log', '--pretty=%B', '-n', '1', rev,]
                        )
                        look_for_marker = False
                        if '--CL--' in out:
                            look_for_marker = True
                        # None implies changelog entry not yet seen
                        top_level_changelog = None
                        message = ''
                        for s in out.splitlines():
                            # Skip blanklines
                            if not s:
                                continue
                            if look_for_marker:
                                if '--CL--' in s:
                                    look_for_marker = False
                            else:
                                if '--CL--' in s:
                                    break
                                if top_level_changelog is None:
                                    top_level_changelog = False
                                    if s.strip().startswith('*'):
                                        top_level_changelog = True
                                # Don't close old bugs, but close new bugs
                                if top_level_changelog is False:
                                    s = s.replace('LP:', 'LP')
                                message += s + '\n'
                        fp.write(message)
                else:
                    fp.write(line)
            fp.flush()
            shutil.move(fp.name, dch.name)
            dch.flush()
    repo.git_run(['commit', '-m', 'reconstruct-changelog', 'debian/changelog'])

def do_update_maintainer(repo):
    run(['update-maintainer'])
    paths = []
    for path in ['debian/control', 'debian/control.in']:
        if os.path.exists(path):
            paths.append(path)
    cmd = ['commit', '-m', 'update-maintainer']
    cmd.extend(paths)
    repo.git_run(cmd)

def do_finish(repo, tag_prefix, commitish, merge_base_commit_hash, onto, release, bug, force):
    # 0) check is either new/debian or lp#/new/debian is an ancestor
    # of HEAD
    ancestor_check = False
    ancestors_checked = set()
    # if --bug was not passed, merge-base will fail because it
    # can't find the bug-specific tag
    try:
        ancestors_checked.add('%snew/debian' % tag_prefix)
        repo.git_run(
            [
                'merge-base',
                '--is-ancestor',
                '%snew/debian' % tag_prefix,
                'HEAD',
            ],
            verbose_on_failure=False,
        )
        ancestor_check = True
    except subprocess.CalledProcessError as e:
        try:
            ancestors_checked.add('new/debian')
            repo.git_run(
                ['merge-base', '--is-ancestor', 'new/debian', 'HEAD'],
                verbose_on_failure=False,
            )
            ancestor_check = True
        except subprocess.CalledProcessError as e:
            pass

    if not ancestor_check:
        if len(ancestors_checked) > 1:
            msg = ' and '.join(ancestors_checked) + ' are not ancestors'
        else:
            msg = '%s is not an ancestor' % ancestors_checked.pop()
        if force:
            logging.error("%s of the HEAD commit, but --force passed.", msg)
        else:
            logging.error(
                "%s of the HEAD commit. Did you run `git-ubuntu merge "
                "start` first? (Pass -f to force the merge).",
                msg
            )
            return 1

    # 1) git merge-changelogs old/ubuntu old/debian new/debian
    do_merge_changelogs(repo, commitish, merge_base_commit_hash, onto)
    # 2) git reconstruct-changelog <onto>
    do_reconstruct_changelog(repo, onto, release, bug)
    # TODO elide from each entry any lines outside of changelog-markers
    # (add flag to specify it?)
    # 3) update-maintainer
    do_update_maintainer(repo)
    return 0


def main(
    directory,
    commitish,
    onto,
    force,
    tag_only,
    bug,
    release,
    subcommand,
):
    """Entry point to merge

    Arguments:
    @directory: string path to directory containing local repository
    @commitish: string commitish to merge
    @onto: string commitish to merge to
    @force: if True, overwrite objects in the repository rather than
    erroring
    @tag_only: if True, only create tags in the local repository
    @bug: string bug number closed by this merge
    @release: string Ubuntu release to target this merge to
    @subcommand: string merge stage to run, one of 'start' or 'finish'

    Returns 0 if the subcommand completes successfully; 1 otherwise.
    """
    repo = GitUbuntuRepository(directory)
    tag_prefix = ''
    if bug:
        tag_prefix = 'lp%s/' % bug

    try:
        commitish_obj = repo.get_commitish(commitish)
    except KeyError:
        logging.error(
            "%s is not a defined object in this git repository.",
            commitish
        )
        return 1

    commitish_version, _ = repo.get_changelog_versions_from_treeish(
        str(commitish_obj.id),
    )

    try:
        onto_obj = repo.get_commitish(onto)
        onto_version, _ = repo.get_changelog_versions_from_treeish(onto)
        if version_compare(commitish_version, onto_version) >= 0:
            if force:
                logging.info(
                    "%s version (%s) is after %s version (%s), "
                    "but --force passed.",
                    commitish,
                    commitish_version,
                    onto,
                    onto_version,
                )
            else:
                logging.error(
                    "%s version (%s) is after %s version (%s). "
                    "Are you sure you want to merge? "
                    "(Pass -f to force the merge).",
                    commitish,
                    commitish_version,
                    onto,
                    onto_version,
                )
                return 1
    except KeyError:
        logging.info("%s is not a defined object in this git repository.", onto)
        logging.info(
            "Creating a local branch named %s tracking pkg/%s.",
            onto,
            onto,
        )
        try:
            branch = repo.create_tracking_branch(
                onto,
                'pkg/%s' % onto,
                force=force,
            )
        except:
            logging.error(
                "Failed to create local branch (%s). "
                "Does it already exist (pass -f)?",
                onto,
            )
            return 1
        onto_obj = repo.get_commitish(onto)

    stdout, _ = run(['git', 'status', '--porcelain'])
    if len(stdout) > 0:
        logging.error('Working tree must be clean to continue:')
        logging.error(stdout)
        return 1

    merge_base_commit_hash = repo.find_ubuntu_merge_base(commitish)
    if not merge_base_commit_hash:
        logging.error(
            "Unable to find a common ancestor for %s and %s.",
            onto,
            commitish,
        )
        return 1

    if merge_base_commit_hash == str(onto_obj.id):
        logging.error(
            "The common ancestor of %s and %s is "
            "identical to %s. No merge is necessary.",
            onto,
            commitish,
            onto,
        )
        return 1

    merge_base_version, _ = repo.get_changelog_versions_from_treeish(
        merge_base_commit_hash,
    )

    if subcommand == 'start':
        return do_start(
            repo,
            tag_prefix,
            commitish,
            merge_base_commit_hash,
            onto,
            force,
            tag_only,
        )
    elif subcommand == 'finish':
        return do_finish(
            repo,
            tag_prefix,
            commitish,
            merge_base_commit_hash,
            onto,
            release,
            bug,
            force,
        )
