# vim: set fileencoding=utf-8 :
#
# (C) 2007, 2008, 2009, 2010, 2013 Guido Guenther <agx@sigxcpu.org>
# (C) 2014 Intel Corporation <markus.lehtonen@linux.intel.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., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
"""Generate RPM changelog entries from git commit messages"""

import configparser
from datetime import datetime
import os.path
import pwd
import re
import sys
import socket

import gbp.command_wrappers as gbpc
import gbp.log
from gbp.config import GbpOptionParserRpm, GbpOptionGroup
from gbp.errors import GbpError
from gbp.git.modifier import GitModifier
from gbp.rpm import guess_spec, NoSpecError, SpecFile
from gbp.rpm.changelog import Changelog, ChangelogParser, ChangelogError
from gbp.rpm.git import GitRepositoryError, RpmGitRepository
from gbp.rpm.policy import RpmPkgPolicy
from gbp.scripts.buildpackage_rpm import packaging_tag_name
from gbp.scripts.buildpackage_rpm import create_packaging_tag


ChangelogEntryFormatter = RpmPkgPolicy.ChangelogEntryFormatter


class ChangelogFile(object):
    """Container for changelog file, whether it be a standalone changelog
       or a spec file"""

    def __init__(self, file_path):
        parser = ChangelogParser(RpmPkgPolicy)

        if os.path.splitext(file_path)[1] == '.spec':
            gbp.log.debug("Using spec file '%s' as changelog" % file_path)
            self._file = SpecFile(file_path)
            self.changelog = parser.raw_parse_string(self._file.get_changelog())
        else:
            self._file = os.path.abspath(file_path)
            if not os.path.exists(file_path):
                gbp.log.info("Changelog '%s' not found, creating new "
                             "changelog file" % file_path)
                self.changelog = Changelog(RpmPkgPolicy)
            else:
                gbp.log.debug("Using changelog file '%s'" % file_path)
                self.changelog = parser.raw_parse_file(self._file)

        # Parse topmost section and try to determine the start commit
        if self.changelog.sections:
            self.changelog.sections[0] = parser.parse_section(
                    self.changelog.sections[0])

    def write(self):
        """Write changelog file to disk"""
        if isinstance(self._file, SpecFile):
            self._file.set_changelog(str(self.changelog))
            self._file.write_spec_file()
        else:
            with open(self._file, 'w') as fobj:
                fobj.write(str(self.changelog))

    @property
    def path(self):
        """File path"""
        if isinstance(self._file, SpecFile):
            return self._file.specpath
        else:
            return self._file

def load_customizations(customization_file):
    """Load user defined customizations file"""
    # Load customization file
    if not customization_file:
        return
    customizations = {}
    try:
        with open(customization_file) as f:
            exec(f.read(), customizations, customizations)
    except Exception as err:
        raise GbpError("Failed to load customization file: %s" % err)

    # Set customization classes / functions
    global ChangelogEntryFormatter
    if 'ChangelogEntryFormatter' in customizations:
        ChangelogEntryFormatter = customizations.get('ChangelogEntryFormatter')


def determine_editor(options):
    """Determine text editor"""

    # Check if we need to spawn an editor
    states = ['always']
    if options.release:
        states.append('release')
    if options.spawn_editor not in states:
        return None

    # Determine the correct editor
    if options.editor_cmd:
        return options.editor_cmd
    elif 'EDITOR' in os.environ:
        return os.environ['EDITOR']
    else:
        return 'vi'


def check_repo_state(repo, options):
    """Check that the repository is in good state"""
    # Check branch
    branch = repo.get_branch()
    if options.packaging_branch != branch and not options.ignore_branch:
        gbp.log.err("You are not on branch '%s' but on '%s'" %
                    (options.packaging_branch, branch))
        raise GbpError("Use --ignore-branch to ignore or "
                       "--packaging-branch to set the branch name.")
    # Check unstaged changes
    if options.commit:
        unstaged = []
        status = repo.status()
        for group, files in status.items():
            if group != '??' and group[1] != ' ':
                unstaged.extend(files)
        if unstaged:
            gbp.log.error("Unstaged changes in:\n    %s" %
                          '\n    '.join(unstaged))
            raise GbpError("Please commit or stage your changes before using "
                           "the --commit or --tag option")


def parse_spec_file(repo, options):
    """Find and parse spec file"""
    if options.spec_file != 'auto':
        spec_path = os.path.join(repo.path, options.spec_file)
        spec = SpecFile(spec_path)
    else:
        spec = guess_spec(os.path.join(repo.path, options.packaging_dir),
                          True, os.path.basename(repo.path) + '.spec')
    options.packaging_dir = spec.specdir
    return spec


def parse_changelog_file(repo, spec, options):
    """Find and parse changelog file"""
    changes_file_name = os.path.splitext(spec.specfile)[0] + '.changes'
    changes_file_path = os.path.join(options.packaging_dir, changes_file_name)

    # Determine changelog file path
    if options.changelog_file == "SPEC":
        changelog_path = spec.specpath
    elif options.changelog_file == "CHANGES":
        changelog_path = changes_file_path
    elif options.changelog_file == 'auto':
        if os.path.exists(changes_file_path):
            changelog_path = changes_file_path
        else:
            changelog_path = spec.specpath
    else:
        changelog_path = os.path.join(repo.path, options.changelog_file)

    return ChangelogFile(changelog_path)


def guess_commit(section, repo, options):
    """Guess the last commit documented in a changelog header"""

    if not section:
        return None
    header = section.header

    # Try to parse the fields from the header revision
    rev_re = '^%s$' % re.sub(r'%\((\S+?)\)s', r'(?P<\1>\\S+)',
                             options.changelog_revision)
    match = re.match(rev_re, header['revision'], re.I)
    fields = match.groupdict() if match else {}

    # First, try to find tag-name, if present
    if 'tagname' in fields:
        gbp.log.debug("Trying to find tagname %s" % fields['tagname'])
        try:
            return repo.rev_parse("%s^0" % fields['tagname'])
        except GitRepositoryError:
            gbp.log.warn("Changelog points to tagname '%s' which is not found "
                         "in the git repository" % fields['tagname'])

    # Next, try to find packaging tag matching the version
    tag_str_fields = {'vendor': options.vendor}
    if 'version' in fields:
        gbp.log.debug("Trying to find packaging tag for version '%s'" %
                      fields['version'])
        full_version = fields['version']
        tag_str_fields.update(RpmPkgPolicy.split_full_version(full_version))
    elif 'upstreamversion' in fields:
        gbp.log.debug("Trying to find packaging tag for version '%s'" %
                      fields['upstreamversion'])
        tag_str_fields['upstreamversion'] = fields['upstreamversion']
        if 'release' in fields:
            tag_str_fields['release'] = fields['release']
    commit = repo.find_version(options.packaging_tag,
                                   tag_str_fields)
    if commit:
        return commit
    else:
        gbp.log.info("Couldn't find packaging tag for version %s" %
                     header['revision'])

    # As a last resort we look at the timestamp
    timestamp = header['time'].isoformat()
    last = repo.get_commits(num=1, options="--until='%s'" % timestamp)
    if last:
        gbp.log.info("Using commit (%s) before the last changelog timestamp "
                     "(%s)" % (last, timestamp))
        return last[0]
    return None


def get_start_commit(changelog, repo, options):
    """Get the start commit from which to generate new entries"""
    if options.all:
        since = None
    elif options.since:
        since = options.since
    else:
        if changelog.sections:
            since = guess_commit(changelog.sections[0], repo, options)
        else:
            since = None
        if not since:
            raise GbpError("Couldn't determine starting point from "
                           "changelog, please use the '--since' or '--all'")
        gbp.log.info("Continuing from commit '%s'" % since)
    return since


def get_author(repo, use_git_config):
    """Get author and email from git configuration"""
    author = email = None

    if use_git_config:
        modifier = repo.get_author_info()
        author = modifier.name
        email = modifier.email

    passwd_data = pwd.getpwuid(os.getuid())
    if not author:
        # On some distros (Ubuntu, at least) the gecos field has it's own
        # internal structure of comma-separated fields
        author = passwd_data.pw_gecos.split(',')[0].strip()
        if not author:
            author = passwd_data.pw_name
    if not email:
        if 'EMAIL' in os.environ:
            email = os.environ['EMAIL']
        else:
            email = "%s@%s" % (passwd_data.pw_name, socket.getfqdn())

    return author, email


def entries_from_commits(changelog, repo, commits, options):
    """Generate a list of formatted changelog entries from a list of commits"""
    entries = []
    for commit in commits:
        info = repo.get_commit_info(commit)
        entry_text = ChangelogEntryFormatter.compose(info, full=options.full,
                        ignore_re=options.ignore_regex, id_len=options.idlen,
                        meta_bts=options.meta_bts)
        if entry_text:
            entries.append(changelog.create_entry(author=info['author'].name,
                                                  text=entry_text))
    return entries


def entries_from_text(changelog, text, author):
    """Generate a list of changelog entries from a string"""
    entries = []
    # Use current user as the author for all entries
    for line in text.splitlines():
        if line.strip():
            entry_text = "- %s" % line.strip()
            entries.append(changelog.create_entry(author=author,
                                                  text=entry_text))
    return entries


def generate_new_entries(changelog, repo, options, args):
    """Generate new entries to be appended to changelog"""
    if options.message:
        author = get_author(repo, options.git_author)[0]
        entries = entries_from_text(changelog, options.message, author)
    else:
        # Get range of commits from where to generate changes
        since = get_start_commit(changelog, repo, options)
        if args:
            gbp.log.info("Only looking for changes in '%s'" % ", ".join(args))
        commits = repo.get_commits(since=since, until='HEAD', paths=args,
                                   options=options.git_log.split(" "))
        commits.reverse()
        if not commits:
            gbp.log.info("No changes detected from %s to %s." % (since, 'HEAD'))
        entries = entries_from_commits(changelog, repo, commits, options)
    return entries


def update_changelog(changelog, entries, repo, spec, options):
    """Update the changelog with a range of commits"""
    # Get info for section header
    now = datetime.now()
    name, email = get_author(repo, options.git_author)
    rev_str_fields = dict(spec.version,
                version=RpmPkgPolicy.compose_full_version(spec.version),
                vendor=options.vendor)
    if options.commit:
        # Get fake information for the to-be-created git commit
        commit_info = {'author': GitModifier(date=now),
                       'committer': GitModifier(date=now)}
        tag = packaging_tag_name(repo, spec, commit_info, options)
    else:
        commit_info = {'author': None, 'committer': None}
        tag = repo.describe('HEAD', longfmt=True, always=True)
    rev_str_fields['tagname'] = tag

    try:
        revision = options.changelog_revision % rev_str_fields
    except KeyError as err:
        raise GbpError("Unable to construct revision field: unknown key "
                "%s, only %s are accepted" % (err, list(rev_str_fields.keys())))

    # Add a new changelog section if new release or an empty changelog
    if options.release or not changelog.sections:
        top_section = changelog.add_section(time=now, name=name,
                                            email=email, revision=revision)
    else:
        # Re-use already parsed top section
        top_section = changelog.sections[0]
        top_section.set_header(time=now, name=name,
                               email=email, revision=revision)

    # Add new entries to the topmost section
    for entry in entries:
        top_section.append_entry(entry)
    return (tag, commit_info['author'], commit_info['committer'])

def create_commit_message(spec, options):
    """Generate commit message"""
    fields = spec.version
    fields['version'] = version=RpmPkgPolicy.compose_full_version(spec.version)
    fields['vendor'] = options.vendor
    try:
        return options.commit_msg % fields
    except KeyError as err:
        raise GbpError("Unknown key %s in commit-msg string, "
                       "only %s are accepted" % (err, list(fields.keys())))

def commit_changelog(repo, changelog, message, author, committer, edit):
    """Commit changelog and create a packaging/release tag"""
    repo.add_files(changelog.path)
    repo.commit_staged(message, author_info=author, committer_info=committer,
                       edit=edit)


def parse_args(argv):
    """Parse command line and config file options"""
    try:
        parser = GbpOptionParserRpm(command=os.path.basename(argv[0]),
                                    prefix='', usage='%prog [options] paths')
    except configparser.ParsingError as err:
        gbp.log.error('invalid config file: %s' % err)
        return None, None

    range_grp = GbpOptionGroup(parser, "commit range options",
                    "which commits to add to the changelog")
    format_grp = GbpOptionGroup(parser, "changelog entry formatting",
                    "how to format the changelog entries")
    naming_grp = GbpOptionGroup(parser, "naming",
                    "branch names, tag formats, directory and file naming")
    commit_grp = GbpOptionGroup(parser, "commit",
                    "automatic committing and tagging")
    parser.add_option_group(range_grp)
    parser.add_option_group(format_grp)
    parser.add_option_group(naming_grp)
    parser.add_option_group(commit_grp)

    # Non-grouped options
    parser.add_option("-v", "--verbose", action="store_true", dest="verbose",
                    help="verbose command execution")
    parser.add_config_file_option(option_name="color", dest="color",
                    type='tristate')
    parser.add_config_file_option(option_name="color-scheme",
                    dest="color_scheme")
    parser.add_config_file_option(option_name="vendor", action="store",
                    dest="vendor")
    parser.add_config_file_option(option_name="git-log", dest="git_log",
                    help="options to pass to git-log, default is '%(git-log)s'")
    parser.add_boolean_config_file_option(option_name="ignore-branch",
                    dest="ignore_branch")
    parser.add_config_file_option(option_name="customizations",
                    dest="customization_file",
                    help="Load Python code from CUSTOMIZATION_FILE. At the "
                         "moment, the only useful thing the code can do is "
                         "define a custom ChangelogEntryFormatter class.")
    # Naming group options
    naming_grp.add_config_file_option(option_name="packaging-branch",
                    dest="packaging_branch")
    naming_grp.add_config_file_option(option_name="packaging-tag",
                    dest="packaging_tag")
    naming_grp.add_config_file_option(option_name="packaging-dir",
                    dest="packaging_dir")
    naming_grp.add_config_file_option(option_name="changelog-file",
                    dest="changelog_file")
    naming_grp.add_config_file_option(option_name="spec-file", dest="spec_file")
    # Range group options
    range_grp.add_option("-s", "--since", dest="since",
                    help="commit to start from (e.g. HEAD^^^, release/0.1.2)")
    range_grp.add_option("--all", action="store_true",
                    help="use all commits from the Git history, overrides "
                         "--since")
    # Formatting group options
    format_grp.add_config_file_option(option_name="meta-bts", dest="meta_bts")
    format_grp.add_option("--no-release", action="store_false", default=True,
                    dest="release",
                    help="no release, just update the last changelog section")
    format_grp.add_boolean_config_file_option(option_name="git-author",
                    dest="git_author")
    format_grp.add_boolean_config_file_option(option_name="full", dest="full")
    format_grp.add_config_file_option(option_name="id-length", dest="idlen",
                    help="include N digits of the commit id in the changelog "
                         "entry, default is '%(id-length)s'",
                    type="int", metavar="N")
    format_grp.add_config_file_option(option_name="ignore-regex",
                    dest="ignore_regex",
                    help="Ignore lines in commit message matching regex, "
                         "default is '%(ignore-regex)s'")
    format_grp.add_config_file_option(option_name="changelog-revision",
                    dest="changelog_revision")
    format_grp.add_config_file_option(option_name="spawn-editor",
                    dest="spawn_editor")
    format_grp.add_config_file_option(option_name="editor-cmd",
                    dest="editor_cmd")
    format_grp.add_option("-m", '--message',
                    help="text to use as new changelog entries - git commit "
                         "messages and the --since are ignored in this case")
    # Commit/tag group options
    commit_grp.add_option("-c", "--commit", action="store_true",
                    help="commit changes")
    commit_grp.add_config_file_option(option_name="commit-msg",
                                      dest="commit_msg")
    commit_grp.add_option("--tag", action="store_true",
                    help="commit the changes and create a packaging/release"
                         "tag")
    commit_grp.add_option("--retag", action="store_true",
                    help="Overwrite packaging tag if it already exists")
    commit_grp.add_boolean_config_file_option(option_name="sign-tags",
                    dest="sign_tags")
    commit_grp.add_config_file_option(option_name="keyid", dest="keyid")

    options, args = parser.parse_args(argv[1:])
    if options.tag:
        options.commit = True
    if not options.changelog_revision:
        options.changelog_revision = RpmPkgPolicy.Changelog.header_rev_format

    gbp.log.setup(options.color, options.verbose, options.color_scheme)

    return options, args

def main(argv):
    """Script main function"""
    options, args = parse_args(argv)
    if not options:
        return 1

    try:
        load_customizations(options.customization_file)
        editor_cmd = determine_editor(options)

        repo = RpmGitRepository('.')
        check_repo_state(repo, options)

        # Find and parse spec file
        spec = parse_spec_file(repo, options)

        # Find and parse changelog file
        ch_file = parse_changelog_file(repo, spec, options)

        # Get new entries
        entries = generate_new_entries(ch_file.changelog, repo, options, args)

        # Do the actual update
        tag, author, committer = update_changelog(ch_file.changelog, entries,
                                                  repo, spec, options)
        # Write to file
        ch_file.write()

        if editor_cmd and not options.message:
            gbpc.Command(editor_cmd, [ch_file.path])()

        if options.commit:
            edit = True if editor_cmd else False
            msg = create_commit_message(spec, options)
            commit_changelog(repo, ch_file, msg, author, committer, edit)
            if options.tag:
                if options.retag and repo.has_tag(tag):
                    repo.delete_tag(tag)
                create_packaging_tag(repo, tag, 'HEAD', spec.version, options)

    except (GbpError, GitRepositoryError, ChangelogError, NoSpecError) as err:
        if len(err.__str__()):
            gbp.log.err(err)
        return 1
    return 0

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