#!/usr/bin/env python
"""avakas

The avakas tool is meant as an interface around version
metadata for assorted flavours of software projects.

For more information see https://github.com/otakup0pe/avakas
"""

from __future__ import print_function
import os
import re
import json
import sys
from optparse import OptionParser
from glob import glob
import contextlib
from semantic_version import Version
from git import Repo
from erl_terms import decode as erl_decode


@contextlib.contextmanager
def stdout_redirect():
    """ Forcefully redirect stdout to stderr """
    # http://marc-abramowitz.com/archives/2013/07/19/python-context-manager-for-redirected-stdout-and-stderr/
    try:
        oldstdchannel = os.dup(sys.stdout.fileno())
        os.dup2(sys.stderr.fileno(), sys.stdout.fileno())

        yield
    finally:
        if oldstdchannel is not None:
            os.dup2(oldstdchannel, sys.stdout.fileno())


def usage(parser=None):
    """Display usage syntax."""
    print("avakas show <directory>")
    print("avakas bump <directory> [pre|patch|minor|major]")
    print("avakas set <directory> <version>")
    if parser:
        parser.print_help()


def problems(msg):
    """Simple give-up and error out function."""
    print("Problem: %s" % msg,
          file=sys.stderr)
    sys.exit(1)


def determine_flavor(directory):
    """Determines the project flavour for the given directory."""
    flavor = 'plain'
    if os.path.exists("%s/package.json" % directory):
        flavor = 'node'
    elif os.path.exists("%s/meta/main.yml" % directory):
        flavor = 'ansible'
    elif len(glob("%s/src/*.app.src" % directory)) == 1:
        flavor = 'erlang'
    elif os.path.exists("%s/metadata.rb" % directory):
        flavor = 'chef-cookbook'

    return flavor


def read_package_json(directory):
    """Reads the version from package.json."""
    package_file = "%s/package.json" % directory
    if not os.path.exists(package_file):
        problems("The %s file is missing" % package_file)

    package_handle = open(package_file, 'r')
    package_json = json.load(package_handle)
    package_handle.close()

    return package_json


def write_plain_version(directory, version, opt):
    """Writes the version to a plain text file."""
    version_file = "%s/%s" % (directory, opt.filename)
    plain_handle = open(version_file, 'w')
    plain_handle.write(str(version))
    plain_handle.close()


def write_node_version(directory, version):
    """Writes the version to package.json."""
    package_json = read_package_json(directory)
    package_json['version'] = str(version)
    package_file = "%s/package.json" % directory
    package_handle = open(package_file, 'w')
    json.dump(package_json,
              package_handle,
              indent=4,
              separators=(',', ': '),
              sort_keys=True)
    package_handle.close()


def write_erlang_version(directory, version):
    """Writes the version to foo.app.src."""
    app_file = glob("%s/src/*.app.src" % directory)[0]
    app_handle = open(app_file, 'r')
    lines = []
    updated = False
    for line in app_handle:
        re_out = re.sub(r'(.+vsn.+")(.+)(".+)', r'\1%s\3', line)
        if re_out != line:
            updated = True
            lines.append(re_out % version)
        else:
            lines.append(line)

    app_handle.close()
    if not updated:
        problems("Unable to edit file %s" % app_file)
    else:
        app_handle = open(app_file, 'w')
        app_handle.write(''.join(lines))
        app_handle.close()


def write_cookbook_version(directory, version):
    """Writes the version to metadata.rb"""
    metadata_file = "%s/metadata.rb" % directory
    metadata_handle = open(metadata_file, 'r')
    lines = []
    updated = False
    for line in metadata_handle:
        pattern = r'^(version.+["\'])(\d+\.\d+\.\d+)(["\'].*)'
        re_out = re.sub(pattern, r'\1%s\3', line)
        if re_out != line:
            updated = True
            lines.append(re_out % version)
        else:
            lines.append(line)

    metadata_handle.close()
    if not updated:
        problems("Unable to edit file %s" % metadata_file)
    else:
        metadata_handle = open(metadata_file, 'w')
        metadata_handle.write(''.join(lines))
        metadata_handle.close()


def git_push(repo, opt, tag=None):
    """Pushes the repository to our remote."""
    if tag:
        info = repo.remotes[opt.remote].push(tag)
    else:
        info = repo.remotes[opt.remote].push()
    info = info[0]
    if info.flags & 1024 or info.flags & 32 or info.flags & 16:
        problems("Unexpected git error: %s" % info.summary)


def grok_vsn_file(directory):
    """Determines what our actual file to be modified is"""
    flav = determine_flavor(directory)
    vsn_file = None
    if flav == 'node':
        vsn_file = "%s/package.json" % directory
    elif flav == 'erlang':
        app_file = glob("%s/src/*.app.src" % directory)
        vsn_file = "%s/%s" % (directory, app_file)
    elif flav == 'chef-cookbook':
        vsn_file = '%s/metadata.rb' % directory
    elif flav != 'ansible':
        vsn_file = "%s/version" % directory

    return vsn_file


def write_git(repo, directory, vsn_str, opt):
    """Will commit and push the version file and optionally tags."""
    if isinstance(vsn_str, str):
        version = Version(vsn_str)
    else:
        version = vsn_str
        vsn_str = str(version)

    if opt.tag_prefix:
        tag = "%s%s" % (opt.tag_prefix, vsn_str)
    else:
        tag = vsn_str

    if opt.dry:
        print("Would have pushed %s to %s." % (vsn_str, opt.remote),
              file=sys.stderr)
        if not version.build:
            print("Would have tagged as %s." % tag,
                  file=sys.stderr)

        return

    vsn_file = grok_vsn_file(directory)

    if vsn_file:
        repo.index.add([vsn_file])
        skip_hooks = True
        if opt.with_hooks:
            skip_hooks = False

        repo.index.commit("Version bumped to %s" % vsn_str,
                          skip_hooks=skip_hooks)
        git_push(repo, opt)

    if not version.build:
        repo.create_tag(tag)
        git_push(repo, opt, tag)


def load_git(directory, opt):
    """Initializes our local git workspace."""
    repo = get_repo(directory)
    if not repo:
        problems("Unable to find associated git repo for %s." % directory)

    if repo.is_dirty():
        problems("Git repo dirty.")

    if opt.branch not in repo.heads:
        problems("Branch %s branch not found." % opt.branch)

    if repo.active_branch != repo.heads[opt.branch]:
        print("Switching to %s branch" % opt.branch,
              file=sys.stderr)
        repo.heads[opt.branch].checkout()
    else:
        print("Already on %s branch" % opt.branch,
              file=sys.stderr)

    if opt.remote not in [r.name for r in repo.remotes]:
        problems("Remote %s not found" % opt.remote)

    # we really do not want to be polluting our stdout when showing the version
    with stdout_redirect():
        repo.remotes[opt.remote].pull(refspec=opt.branch)

    return repo


def transmogrify_version(version, bump):
    """Update the version string."""
    new_version = None
    if bump == 'patch':
        new_version = version.next_patch()
    elif bump == 'minor':
        new_version = version.next_minor()
    elif bump == 'major':
        new_version = version.next_major()
    elif bump == 'pre':
        new_version = Version(str(version))
        prereleases = len(new_version.prerelease)
        if prereleases == 1:
            new_version.prerelease = (str(int(new_version.prerelease[0]) + 1))
        elif prereleases == 0:
            new_version.prerelease = ('1')
        else:
            problems("Unexpected version prerelease")

    else:
        problems("Invalid version component")

    return new_version


def get_repo(directory):
    """Load the git repository."""
    return Repo(directory, search_parent_directories=True)


def git_rev(directory):
    """Returns the first eight characters of HEAD"""
    return str(get_repo(directory).head.commit)[0:8]


def extract_node_version(directory):
    """Extract just the version from a nodejs project."""
    package_json = read_package_json(directory)
    version = package_json['version']
    return Version(version)


def extract_erlang_version(directory):
    """Extract just the vesion from an Erlang/OTP application."""
    app_file = glob("%s/src/*.app.src" % directory)[0]
    version_handle = open(app_file, 'r')
    erl_terms = erl_decode(version_handle.read())
    version_handle.close()
    app_config = erl_terms[0][2]
    erlang_version = None
    for config in app_config:
        if config[0] == 'vsn':
            erlang_version = Version(config[1])

    if not erlang_version:
        problems("Something wrong with OTP app file " % app_file)

    return erlang_version


def extract_ansible_version(repo, opt):
    """Extract the version of an Ansible Galaxy role from git tags."""
    raw_tags = [t.name for t in repo.tags]
    unsorted_tags = []
    prefix = opt.tag_prefix
    for tag in raw_tags:
        if prefix:
            if tag[0:len(prefix)] == prefix:
                unsorted_tags.append(str(Version(tag[len(prefix):])))
        else:
            try:
                version = Version(tag)
                unsorted_tags.append(str(version))
            except ValueError:
                continue

    tags = sorted(unsorted_tags)
    tags.reverse()
    if tags:
        return Version(tags[0])

    return None


def extract_plain_version(directory, opt):
    """Extract just the version from a generic project."""
    version_file = "%s/%s" % (directory, opt.filename)
    if not os.path.exists(version_file):
        problems("The version file %s is missing" % version_file)

    version_handle = open(version_file, 'r')
    version = version_handle.read()
    version_handle.close()
    return Version(version)


def extract_cookbook_version(directory):
    """Extract the version from Chef Cookbook metadata"""
    metadata_handle = open("%s/metadata.rb" % directory, 'r')
    metadata = metadata_handle.read()
    metadata_handle.close()
    pattern = r'^version.+["\'](?P<vsn>\d+\.\d+\.\d+)["\'].*'
    vsn_match = re.compile(pattern, re.MULTILINE).search(metadata)
    return Version(vsn_match.group('vsn'))


def bump_auto(artifact_version, repo, opt):
    """Will go through the Git history until the last version bump
    and look for hints that we want to "automatically" bump
    our version"""
    vsn = None
    reg = re.compile(r'bump:(?P<bump>(patch|minor|major)).*', re.MULTILINE)
    for commit in repo.iter_commits(opt.branch):
        # we go iterate back to the last time we bumped the version
        if commit.message.startswith('Version bumped to'):
            break

        res = reg.search(commit.message)
        if res:
            bump = res.group('bump')
            if not vsn:
                vsn = bump
            elif vsn == 'patch' and bump == 'minor':
                vsn = 'minor'
            elif vsn == 'patch' and bump == 'major':
                vsn = 'major'
            elif vsn == 'minor' and bump == 'major':
                vsn = 'major'

    if not vsn:
        print("No auto bump indicators", file=sys.stderr)
        sys.exit(0)

    return transmogrify_version(artifact_version, vsn)


def bump_version(repo, directory, bump, opt):
    """Bump the flavour specific version for a project."""
    flavor = determine_flavor(directory)

    if flavor == 'node':
        artifact_version = extract_node_version(directory)
    elif flavor == 'erlang':
        artifact_version = extract_erlang_version(directory)
    elif flavor == 'ansible':
        artifact_version = extract_ansible_version(repo, opt)
    elif flavor == 'chef-cookbook':
        artifact_version = extract_cookbook_version(directory)
    else:
        artifact_version = extract_plain_version(directory, opt)

    if bump == 'auto':
        new_version = bump_auto(artifact_version, repo, opt)
    else:
        new_version = transmogrify_version(artifact_version, bump)

    if flavor == 'node':
        write_node_version(directory, new_version)
    elif flavor == 'erlang':
        write_erlang_version(directory, new_version)
    elif flavor == 'chef-cookbook':
        write_cookbook_version(directory, new_version)
    elif flavor != 'ansible':
        write_plain_version(directory, new_version, opt)

    print("Version updated from %s to %s" % (artifact_version, new_version))
    return new_version


def set_version(directory, version, opt):
    """Manually set the flavour specific version for a project."""
    try:
        version = Version(version)
    except ValueError:
        problems("Invalid version string %s" % version)

    flavor = determine_flavor(directory)
    if flavor == 'node':
        write_node_version(directory, version)
    elif flavor == 'erlang':
        write_erlang_version(directory, version)
    elif flavor == 'chef-cookbook':
        write_cookbook_version(directory, version)
    elif flavor == 'plain':
        write_plain_version(directory, version, opt)

    print("Version set to %s" % version)


def append_prebuild_version(git_str, artifact_version):
    """Append the prebuild version component if so desired."""
    if artifact_version.prerelease:
        artifact_version.prerelease = artifact_version.prerelease \
                                      + (git_str,)
    else:
        artifact_version.prerelease = [git_str]
    if 'BUILD_NUMBER' in os.environ:
        artifact_version.prerelease.append(os.environ['BUILD_NUMBER'])
    elif 'TRAVIS_BUILD_NUMBER' in os.environ:
        artifact_version.prerelease.append(os.environ['TRAVIS_BUILD_NUMBER'])


def append_build_version(git_str, artifact_version):
    """Append the build version component if so desired."""
    if artifact_version.build:
        artifact_version.build = artifact_version.build \
                                 + (git_str,)
    else:
        artifact_version.build = [git_str]
    if 'BUILD_NUMBER' in os.environ:
        artifact_version.build.append(os.environ['BUILD_NUMBER'])
    elif 'TRAVIS_BUILD_NUMBER' in os.environ:
        artifact_version.build.append(os.environ['TRAVIS_BUILD_NUMBER'])


def show_version(directory, opt):
    """Show the current flavour specific version for a project."""
    flavor = determine_flavor(directory)
    if flavor == 'node':
        artifact_version = extract_node_version(directory)
    elif flavor == 'erlang':
        artifact_version = extract_erlang_version(directory)
    elif flavor == 'ansible':
        repo = load_git(directory, opt)
        artifact_version = extract_ansible_version(repo, opt)
    elif flavor == 'chef-cookbook':
        artifact_version = extract_cookbook_version(directory)
    else:
        artifact_version = extract_plain_version(directory, opt)

    if not artifact_version:
        problems('Unable to extract current version')

    git_str = str(git_rev(directory))
    if opt.build:
        append_build_version(git_str, artifact_version)
    if opt.prebuild:
        append_prebuild_version(git_str, artifact_version)

    print("%s" % str(artifact_version))


def parse_args(parser):
    """Parse our command line arguments."""
    operation = sys.argv[1]
    parser.add_option('--tag-prefix',
                      dest='tag_prefix',
                      help='Prefix for version tag name',
                      default=None)
    parser.add_option('--branch',
                      dest='branch',
                      help='Branch to use when updating git',
                      default='master')
    parser.add_option('--remote',
                      dest='remote',
                      help='Git remote',
                      default='origin')
    parser.add_option('--filename',
                      dest='filename',
                      help='File name. Used for fallback versioning.',
                      default='version')

    if operation in ('set', 'bump'):
        parser.add_option('--with-hooks',
                          dest='with_hooks',
                          help='Run git hooks',
                          default=False)

    if operation == 'show':
        parser.add_option('--build',
                          dest='build',
                          help='Will include build information '
                          'in build semver component',
                          action='store_true')
        parser.add_option('--pre-build',
                          dest='prebuild',
                          help='Will include build information '
                          'in pre-release semver component',
                          action='store_true')
    else:
        parser.add_option('--dry-run',
                          dest='dry',
                          help='Will not push to git',
                          action='store_true')

    (opt, args) = parser.parse_args()
    if operation == 'help':
        usage(parser)
        sys.exit(0)
    else:
        if len(args) < 2:
            usage(parser)
            sys.exit(1)

    return (operation, opt, args)


def main():
    """Dat entrypoint"""
    parser = OptionParser()
    (operation, opt, args) = parse_args(parser)

    directory = os.path.abspath(args[1])

    if not os.path.exists(directory):
        problems("Directory %s does not exist." % directory)

    # Ansible Galaxy expects this prefix
    if determine_flavor(directory) == 'ansible':
        if opt.tag_prefix:
            problems('Cannot specify a tag prefix with an Ansible Role')
        else:
            opt.tag_prefix = 'v'

    if operation == 'bump':
        bump = 'dev'
        if len(args) >= 3:
            bump = args[2].lower()
            if bump in ('patch', 'minor', 'major', 'pre', 'auto'):
                repo = load_git(directory, opt)
                version = bump_version(repo, directory, bump, opt)
                write_git(repo, directory, version, opt)
                sys.exit(0)
    elif operation == 'show':
        if opt.build and opt.prebuild:
            problems('Cannot specify both --build and --prebuild')
        show_version(directory, opt)
        sys.exit(0)
    elif operation == 'set':
        if len(args) == 3:
            repo = load_git(directory, opt)
            version = args[2]
            set_version(directory, version, opt)
            write_git(repo, directory, version, opt)
            sys.exit(0)

    usage(parser)
    sys.exit(1)

if __name__ == "__main__":

    if len(sys.argv) < 2:
        usage()
        sys.exit(1)

    main()
