#!/usr/bin/python3
# -*- 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
from contextlib import suppress
import errno
import glob
import os
import re
import shutil
import sys
import tarfile
import zipfile
import codecs
import logging

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

if HAS_PACKAGING:
    with suppress(ImportError):
        from packaging.version import LegacyVersion

if os.environ.get('DEBUG_SET_VERSION') == "1":
    logging.getLogger().setLevel(logging.DEBUG)

outdir = None
suffixes = ('.obscpio', '.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tbz2',
            '.tar.xz', '.tar.zst', '.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='',
                 versionfile=None):
        self.regex = regex
        self.file_list = file_list
        self.basename = basename
        self.versionfile = versionfile

    def autodetect(self):
        logging.debug("Starting version autodetect")
        logging.debug("-- Starting version detection via specified file")
        version = self._get_version_via_versionfile()
        if not version:
            logging.debug("--- Could not find version via specified file")
            logging.debug("-- Starting version detection via obsinfo")
            version = self._get_version_via_obsinfo()
        if not version:
            logging.debug("--- Could not find version via obsinfo")
            logging.debug("-- Starting version detection via archive dirname")
            version = self._get_version_via_archive_dirname()
        if not version:
            logging.debug("--- Could not find version via archive dirname")
            logging.debug("-- Starting version detection via filename")
            version = self._get_version_via_filename()
        if not version:
            logging.debug("--- Could not find version via filename")
            logging.debug("-- Starting version detection via debian changelog")
            version = self.get_version_via_debian_changelog("debian.changelog")
        if not version:
            logging.debug("--- Could not find version via debian changelog")
        return version

    def _get_version_via_filename(self):
        """ detect version based on file names"""
        logging.debug("detecting version via files")
        for f in self.file_list:
            logging.debug("  - checking file %s", f)
            if self.regex:
                logging.debug("  - using regex: %r", 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_via_versionfile(self):
        """ detect version based on custom file contents"""
        logging.debug("detecting version via custom file")

        if not self.versionfile:
            logging.debug("Custom file name not set")
            return None

        logging.debug("  - checking file '%s'", self.versionfile)

        if self.regex:
            regex = self.regex
        else:
            regex = r"^[Vv]ersion:\s+([\d].*)(?:)\s?$"

        logging.debug("  - using regex: %r", regex)

        if not os.path.exists(self.versionfile):
            logging.debug("  - file: %s does not exist", self.versionfile)
            raise OSError(errno.ENOENT, os.strerror(errno.ENOENT),
                          self.versionfile)

        with codecs.open(self.versionfile, 'r', 'utf8') as fp:
            for line in fp:
                m = re.match(regex, line)
                if m:
                    return m.group(1)
        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):
            logging.debug("Checking path: '%s'.", f)
            if not os.path.isfile(f):
                logging.debug("Skipping path: '%s' is not a regular file.", f)
                continue
            # 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 RevisionDetector(object):

    def __init__(self):
        pass

    def autodetect(self):
        return self._get_revision_from_osc_files()

    @staticmethod
    def _get_revision_from_osc_files():
        from xml.etree import ElementTree
        _filesname = os.path.join('.osc', '_files')
        _filesXML = ElementTree.parse(_filesname)
        return _filesXML.getroot().attrib['rev']


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 not os.path.isfile(f):
            logging.debug("Skipping path: '%s' is not a regular file.", f)
            return False
        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_variable(filename, variable, string):
    # cmake configure_file behavior, replace variables marked with @ sign
    with codecs.open(filename, 'r+', 'utf8') as f:
        contents = f.read()
        f.seek(0)
        contents_new, subs = re.subn(
            r"@{variable}@".format(variable=variable),
            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

    try:
        v = parse(version_pip)
        with suppress(NameError):
            if isinstance(v, LegacyVersion):
                raise InvalidVersion
    except InvalidVersion:
        # Maybe is converted already?
        return None

    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
    else:
        with suppress(NameError):
            if isinstance(v, LegacyVersion):
                # TODO(toabctl): handle setuptools style legacy version
                pass
        pass

    return version_rpm


def _version_detect(args, files_local):
    vdetect = VersionDetector(args['regex'], files_local, args["basename"],
                              args["fromfile"])
    ver = vdetect.autodetect()
    logging.debug("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('--revision',
                        help='use given revision string, do not detect it '
                        'from obs 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')
    parser.add_argument('--fromfile',
                        help='detect version based on the '
                             'file contents and regex')
    args = vars(parser.parse_args())

    version = args['version']
    revision = args['revision']

    outdir = args['outdir']

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

    if args['debug']:
        logging.getLogger().setLevel(logging.DEBUG)
        logging.debug("Running in debug mode")

    files_local = _get_local_files()

    if not version:
        try:
            version = _version_detect(args, files_local)
        except Exception as e:
            print("Detection failed with error: \"", e, "\".")
            sys.exit(-1)

    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
    version_converted = None
    if PackageTypeDetector._get_package_type(files) == "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")
            _replace_variable(filename, 'VERSION', version)
            _replace_variable(filename, 'VERSION-RELEASE', version + "-0")
        else:
            _replace_tag(filename, 'Version', version)
            _replace_variable(filename, 'VERSION', version)
            _replace_variable(filename, 'VERSION-RELEASE', 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")
