#!/usr/bin/python
# -*- coding: utf-8 -*-

# A simple script to update version number in spec, dsc or arch linux files
#
# (C) 2010 by Adrian Schröter <adrian@suse.de>
# (C) 2015 by Thomas Bechtold <tbechtold@suse.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.
# See http://www.gnu.org/licenses/gpl-2.0.html for full license text.

from __future__ import print_function

import argparse
import glob
import os
import re
import shutil
import sys
import tarfile
import zipfile
import codecs

try:
    from packaging.version import LegacyVersion, Version, parse
except ImportError:
    HAS_PACKAGING = False
    import warnings
    warnings.warn("install 'packaging' to improve python package versions",
                  RuntimeWarning)
else:
    HAS_PACKAGING = True


DEBUG = False

outdir = None
suffixes = ('obscpio', 'tar', 'tar.gz', 'tgz', 'tar.bz2', 'tbz2', 'tar.xz',
            'zip')
suffixes_re = "|".join(map(lambda x: re.escape(x), suffixes))


def _get_local_files():
    """ sorted local file list by modification time (newest first)"""
    files = glob.glob('*')
    files.sort(key=lambda x: os.stat(os.path.join(os.getcwd(), x)).st_mtime,
               reverse=True)
    return files


class VersionDetector(object):
    def __init__(self, regex=None, file_list=(), basename=''):
        self.regex = regex
        self.file_list = file_list
        self.basename = basename

    def autodetect(self):
        if DEBUG:
            print("Starting version autodetect")

        version = self._get_version_via_obsinfo()
        if not version:
            if DEBUG:
                print("Could not find version via obsinfo")
            version = self._get_version_via_archive_dirname()
        if not version:
            if DEBUG:
                print("Could not find version via archive dirname")
            version = self._get_version_via_filename()
        if not version:
            if DEBUG:
                print("Could not find version via filename")
            version = self.get_version_via_debian_changelog("debian.changelog")
        if not version:
            if DEBUG:
                print("Could not find version via debian changelog")
        return version

    def _get_version_via_filename(self):
        """ detect version based on file names"""
        if DEBUG:
            print("detecting version via files")
        for f in self.file_list:
            if DEBUG:
                print("  - checking file ", f)
            if self.regex:
                if DEBUG:
                    print("  - using regex: ", self.regex)
                regex = self.regex
            else:
                regex = r"^%s.*[-_]([\d].*)\.(?:%s)$" % (
                    re.escape(self.basename),
                    suffixes_re)
            m = re.match(regex, f)
            if m:
                return m.group(1)
        # Nothing found
        return None

    def __get_version(self, str_list):
        if self.regex:
            regex = self.regex
        else:
            regex = r"%s.*[-_]([\d][^\/]*).*" % self.basename

        for s in str_list:
            m = re.match(regex, s)
            if m:
                return m.group(1)
        # Nothing found
        return None

    def _get_version_via_archive_dirname(self):
        """ detect version based tar'd directory name"""
        for f in filter(lambda x: x.endswith(suffixes), self.file_list):
            # handle tarfiles
            if tarfile.is_tarfile(f):
                with tarfile.open(f) as tf:
                    v = self.__get_version(tf.getnames())
                    if v:
                        return v
            # handle zipfiles
            if zipfile.is_zipfile(f):
                try:
                    with zipfile.ZipFile(f, 'r') as zf:
                        v = self.__get_version(zf.namelist())
                        if v:
                            return v
                # is_zipfile has often false positives and module is
                # crashing on processing
                except OSError:
                    pass
                except IOError:
                    pass
        # Nothing found
        return None

    def _get_version_via_obsinfo(self):
        for fname in filter(lambda x: x.startswith(self.basename) and
                            x.endswith(".obsinfo"), self.file_list):
            if os.path.exists(fname):
                with codecs.open(fname, 'r', 'utf8') as fp:
                    for line in fp:
                        if line.startswith("version: "):
                            string = line[9:]
                            string = string.rstrip()
                            return string
        # Nothing found
        return None

    @staticmethod
    def get_version_via_debian_changelog(filename):
        # from http://anonscm.debian.org/cgit/pkg-python-debian/\
        #      python-debian.git/tree/lib/debian/changelog.py
        topline = re.compile(r'^(\w%(name_chars)s*) \(([^\(\) \t]+)\)'
                             r'((\s+%(name_chars)s+)+)\;'
                             % {'name_chars': '[-+0-9a-z.]'},
                             re.IGNORECASE)
        if os.path.exists(filename):
            with codecs.open(filename, 'r', 'utf8') as f:
                firstline = f.readline()
                topmatch = topline.match(firstline)
                if topmatch:
                    return topmatch.group(2)
        # Nothing found
        return None

    @staticmethod
    def _get_version_via_debian_dsc(filename):
        version = re.compile(r'^Version:([ \t\f\v]*)[^%\n\r]*', re.IGNORECASE)
        if os.path.exists(filename):
            with codecs.open(filename, 'r', 'utf8') as f:
                for line in f:
                    versionmatch = version.match(line)
                    if versionmatch:
                        return versionmatch.group(0)
        # Nothing found
        return None


class PackageTypeDetector(object):
    # pylint: disable=too-few-public-methods
    @staticmethod
    def _get_package_type(files):
        pt_found = False
        for f in filter(lambda x: x.endswith(suffixes), files):
            pt_found = PackageTypeDetector._is_python(f)
            if pt_found:
                return "python"
        # no package type found
        return None

    @staticmethod
    def _is_python(f):
        names = []
        if tarfile.is_tarfile(f):
            with tarfile.open(f) as tf:
                names = tf.getnames()
        if zipfile.is_zipfile(f):
            try:
                with zipfile.ZipFile(f, 'r') as zf:
                    names = zf.namelist()
            except OSError:
                # is_zipfile has often false positives and module is
                # crashing on processing
                pass
        for n in map(lambda x: os.path.normpath(x), names):
            if n.endswith("egg-info/PKG-INFO"):
                return True
        return False


def _replace_define(filename, def_name, def_value, add_if_missing=True):
    # first, modify a copy of filename and then move it
    with codecs.open(filename, 'r+', 'utf8') as f:
        contents = f.read()
        f.seek(0)
        contents_new, subs = re.subn(
            r'^%define {def_name}(\s*)[^%].*'.format(
                def_name=def_name),
            r'%define {def_name}\g<1>{def_value}'.format(
                def_name=def_name, def_value=def_value),
            contents, flags=re.MULTILINE)
        if subs == 0 and add_if_missing:
            # seems there was no define. add new one before 'Name:'
            contents_new, subs = re.subn(
                r'^(Name:.*)$',
                r'%define {def_name} {def_value}\n\n\g<1>'.format(
                    def_name=def_name, def_value=def_value),
                contents, flags=re.MULTILINE)

        f.truncate()
        f.write(contents_new)


def _replace_spec_setup(filename, version_define):
    # first, modify a copy of filename and then move it
    with codecs.open(filename, 'r+', 'utf8') as f:
        contents = f.read()
        f.seek(0)
        # %setup without "-n" uses implicit "-n" as "%{name}-%{version}"
        contents_new, subs = re.subn(
            r'^%setup\s*((?:-q)?)?\s*$',
            r'%setup \1 -n %{{name}}-%{{{version_define}}}'.format(
                version_define=version_define),
            contents, flags=re.MULTILINE)
        if subs == 0:
            # keep inline macros for rpm
            contents_new, subs = re.subn(
                r'^%setup(.*)%{version}(.*)$',
                r'%setup\g<1>%{{{version_define}}}\g<2>'.format(
                    version_define=version_define),
                contents, flags=re.MULTILINE)
        if subs > 0:
            f.truncate()
            f.write(contents_new)


def _replace_tag(filename, tag, string):
    # first, modify a copy of filename and then move it
    with codecs.open(filename, 'r+', 'utf8') as f:
        contents = f.read()
        f.seek(0)
        if filename.endswith("PKGBUILD") or filename.endswith("build.collax"):
            contents_new, subs = re.subn(
                r"^{tag}=.*".format(tag=tag),
                r"{tag}={string}".format(tag=tag, string=string), contents,
                flags=re.MULTILINE)
        else:
            # keep inline macros for rpm
            contents_new, subs = re.subn(
                r'^{tag}:([ \t\f\v]*)[^%\n\r]*'.format(tag=tag),
                r'{tag}:\g<1>{string}'.format(
                    tag=tag, string=string),
                contents, flags=re.MULTILINE)
        if subs > 0:
            f.truncate()
            f.write(contents_new)


def _replace_debian_changelog_version(fname, version_new):
    # first, modify a copy of filename and then move it
    # get current version
    version_current = VersionDetector.get_version_via_debian_changelog(fname)
    with codecs.open(fname, 'r+', 'utf8') as f:
        content_lines = f.readlines()
        f.seek(0)
        content_lines[0] = content_lines[0].replace(
            version_current, version_new, 1)
        f.truncate()
        f.writelines(content_lines)


def _version_python_pip2rpm(version_pip):
    """generate a rpm compatible version from a python pip version"""
    version_rpm = version_pip
    if not HAS_PACKAGING:
        return version_rpm

    v = parse(version_pip)
    if isinstance(v, Version):
        if v.is_prerelease:
            v_rpm = v.public
            # we need to add the 'x' in front of alpha/beta release because
            # in the python world, "1.1a10" > "1.1.dev10"
            # but in the rpm world, "1.1~a10" < "1.1~dev10"
            v_rpm = v_rpm.replace('a', '~xalpha')
            v_rpm = v_rpm.replace('b', '~xbeta')
            v_rpm = v_rpm.replace('rc', '~xrc')
            v_rpm = v_rpm.replace('.dev', '~dev')
            version_rpm = v_rpm
    elif isinstance(v, LegacyVersion):
        # TODO(toabctl): handle setuptools style legacy version
        pass

    return version_rpm


def _version_detect(args, files_local):
    vdetect = VersionDetector(args['regex'], files_local, args["basename"])
    ver = vdetect.autodetect()
    if DEBUG:
        print("Found version '%s'" % ver)

    return ver


if __name__ == '__main__':

    parser = argparse.ArgumentParser(
        description='Open Build Service source service "set_version".'
        'Used to update build description files with a '
        'detected or given version number.')
    parser.add_argument('--outdir', required=True,
                        help='output directory for modified sources')
    parser.add_argument('--version',
                        help='use given version string, do not detect it '
                        'from source files')
    parser.add_argument('--basename', default="",
                        help='detect version based on the file name with '
                        'a given prefix')
    parser.add_argument('--file', action='append',
                        help='modify only this build description. '
                        'maybe used multiple times.')
    parser.add_argument('--debug', default=False,
                        help='Enable more verbose output.')
    parser.add_argument('--regex',
                        help='regex to be used by autodetect')
    args = vars(parser.parse_args())

    version = args['version']

    outdir = args['outdir']

    if not outdir:
        print("no outdir specified")
        sys.exit(-1)

    if args['debug']:
        print("Running in debug mode")
        DEBUG = True

    files_local = _get_local_files()

    if not version:
        version = _version_detect(args, files_local)

    if not version:
        print("unable to detect the version")
        sys.exit(-1)

    # if no files explicitly specified process whole directory
    files = args['file'] or files_local

    # do version convertion if needed
    pack_type = PackageTypeDetector._get_package_type(files)
    version_converted = None
    if pack_type == "python":
        version_converted = _version_python_pip2rpm(version)

    # handle rpm specs
    for f in filter(lambda x: x.endswith(".spec"), files):
        filename = outdir + "/" + f
        shutil.copyfile(f, filename)
        _replace_define(filename, "version_unconverted", version,
                        add_if_missing=False)
        if version_converted and version_converted != version:
            _replace_define(filename, "version_unconverted", version)
            _replace_tag(filename, 'Version', version_converted)
            _replace_spec_setup(filename, "version_unconverted")
        else:
            _replace_tag(filename, 'Version', version)
        _replace_tag(filename, 'Release', "0")

    # handle debian packages
    # append -0 only for non-native packages, otherwise native packages
    # will be half-converted to non-native and break dpkg-buildpackage
    for f in filter(lambda x: x.endswith(".dsc"), files):
        filename = outdir + "/" + f
        shutil.copyfile(f, filename)
        if "-" in VersionDetector._get_version_via_debian_dsc(filename):
            _replace_tag(filename, 'Version', version + "-0")
        else:
            _replace_tag(filename, 'Version', version)

    for f in filter(lambda x: x.endswith(("debian.changelog")), files):
        filename = outdir + "/" + f
        shutil.copyfile(f, filename)
        if "-" in VersionDetector.get_version_via_debian_changelog(filename):
            _replace_debian_changelog_version(filename, version + "-0")
        else:
            _replace_debian_changelog_version(filename, version)

    # handle build.collax recipes
    for f in filter(lambda x: x.endswith(("build.collax")), files):
        filename = outdir + "/" + f
        shutil.copyfile(f, filename)
        _replace_tag(filename, "version", version)
        _replace_tag(filename, "build", "0")

    # handle arch linux PKGBUILD files
    # TODO: Handle the md5sums generation!
    for f in filter(lambda x: x.endswith(("PKGBUILD")), files):
        filename = outdir + "/" + f
        shutil.copyfile(f, filename)
        _replace_tag(filename, "md5sums", "('SKIP')")
        _replace_tag(filename, "sha256sums", "('SKIP')")
        _replace_tag(filename, "pkgver", version)
        _replace_tag(filename, "pkgrel", "0")
