#!/usr/bin/env python3

#
# Copyright (c) 2014, SUSE LINUX Products GmbH
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301
# USA
#
# (Licensed under the LGPLv2.1 or later)
#
#
# Authors: Thomas Bechtold <tbechtold@suse.com>
#

import argparse
import os
import sys
import time
from multiprocessing import Pool

try:
    import xmlrpc.client
except ImportError:
    # Python3 support
    import xmlrpc.client as xmlrpclib

try:
    from functools import total_ordering
except ImportError:
    def total_ordering(cls):
        """Class decorator that fills in missing ordering methods"""
        convert = {
            '__lt__': [('__gt__', lambda self, other: not (self < other or self == other)),
                       ('__le__', lambda self, other: self < other or self == other),
                       ('__ge__', lambda self, other: not self < other)],
            '__le__': [('__ge__', lambda self, other: not self <= other or self == other),
                       ('__lt__', lambda self, other: self <= other and not self == other),
                       ('__gt__', lambda self, other: not self <= other)],
            '__gt__': [('__lt__', lambda self, other: not (self > other or self == other)),
                       ('__ge__', lambda self, other: self > other or self == other),
                       ('__le__', lambda self, other: not self > other)],
            '__ge__': [('__le__', lambda self, other: (not self >= other) or self == other),
                       ('__gt__', lambda self, other: self >= other and not self == other),
                       ('__lt__', lambda self, other: not self >= other)]
        }
        roots = set(dir(cls)) & set(convert)
        if not roots:
            raise ValueError('must define at least one ordering operation: < > <= >=')
        root = max(roots)       # prefer __lt__ to __le__ to __gt__ to __ge__
        for opname, opfunc in convert[root]:
            if opname not in roots:
                opfunc.__name__ = opname
                opfunc.__doc__ = getattr(int, opname).__doc__
                setattr(cls, opname, opfunc)
        return cls


XMLRPC_SERVER_PROXY = 'https://pypi.python.org/pypi'


@total_ordering
class PackageInfo(object):
    """Represents a package info"""
    def __init__(self, type_, name, version, url):
        self.type = type_.strip()
        self.name = name.strip()  # the original name from pypi (without python3- or python- prefix)
        self.version = version.strip()
        self.url = url.strip()

    def __repr__(self):
        return "PackageInfo obj <%s:%s:%s:%s>" % (self.type, self.name, self.version, self.url)

    def __eq__(self, other):
        if hasattr(other, 'name'):
            return self.name == other.name

    def __lt__(self, other):
        if hasattr(other, 'name'):
            return self.name < other.name


def __get_save_file_serial_name(save_file):
    """filename to use to store the latest used changelog serial from pypi"""
    return save_file + "-changelog-serial"


def __get_cached_changelog_last_serial(save_file):
    """try to read the latest changelog serial and return
    the number or None if not available"""
    serial_file = __get_save_file_serial_name(save_file)
    if os.path.exists(serial_file):
        with open(serial_file, 'r') as f:
            return int(f.readline())
    # no changelog serial available
    return None


def __write_cached_changelog_last_serial(save_file, changelog_serial=None):
    """update the cached changelog serial with the current serial from pypi"""
    if not changelog_serial:
        client = xmlrpc.client.ServerProxy(XMLRPC_SERVER_PROXY)
        changelog_serial = str(client.changelog_last_serial())

    serial_file = __get_save_file_serial_name(save_file)
    with open(serial_file, 'w') as sf:
        sf.write("%s\n" % changelog_serial)


def __read_pypi_package_file(save_file):
    """read the given pypi file into a list of PackageInfo objs"""
    packages = list()
    with open(save_file, "r") as f:
        for line in f.readlines():
            if line.startswith('#'):
                continue
            pack_info = PackageInfo(*line.split(':', 3))
            # skip python3-* lines
            if pack_info.name.startswith('python3-'):
                continue
            # remove python- prefix
            if pack_info.name.startswith('python-'):
                pack_info.name = pack_info.name.replace('python-', '', 1)
            # now pack_info.name should have the original name from pypi
            packages.append(pack_info)
    return packages


def __write_pypi_package_file(save_file, packages):
    """write the pypi file. packages is a list of PackageInfo objs"""
    with open(save_file, "w") as o:
        for pi in sorted(packages):
            if pi:
                # FIXME(toabctl): check somehow if py2 and py3 versions are
                # available currently just write python- and
                # python3- names so both versions can be checked
                o.write("%s:python-%s:%s:%s\n" %
                        (pi.type, pi.name, pi.version, pi.url))
                o.write("%s:python3-%s:%s:%s\n" %
                        (pi.type, pi.name, pi.version, pi.url))


def __create_pypi_package_file(save_file):
    """create a new file with version information for pypi packages.
    This step is expensive because it fetches the package list
    from pypi (> 50000 packages) and then does a request for
    every package to get the version information."""

    # get current changelog serial and store in file before doing anything
    # else so we can't lose some changes while we create the file
    __write_cached_changelog_last_serial(save_file)

    try:
        pack_list = __get_package_list()
        sys.stderr.write(
            "Found %s packages. Getting packages details...\n" %
            (len(pack_list)))
        p = Pool(50)
        packages = p.map(__get_package_info, pack_list)
        __write_pypi_package_file(save_file, packages)
    except Exception as e:
        sys.stderr.write("Error while creating the initial pypi file: %s\n" % e)
        # something wrong - delete the serial file so in the next run the
        # the file will be recreated
        serial_file = __get_save_file_serial_name(save_file)
        os.remove(serial_file)
    else:
        sys.stderr.write("Initial pypi file '%s' created\n" % save_file)


def __find_package_name_index(packages, name):
    """find the given name in the packages list or None if not found"""
    for i, pack in enumerate(packages):
        if pack.name == name:
            return i
    return None


def __update_pypi_package_file(save_file, current_changelog):
    """update information of an exisiting file"""
    try:
        if os.path.exists(save_file):
            packages = __read_pypi_package_file(save_file)
            client = xmlrpc.client.ServerProxy(XMLRPC_SERVER_PROXY)
            changelog_serial = str(client.changelog_last_serial())
            changes = client.changelog_since_serial(current_changelog)
            handled_changes = 0
            sys.stderr.write("Started processing %s change requests...\n" % len(changes))
            for (name, version, timestamp, action, serial) in changes:
                # TODO(toabctl): do it in parallel to improve speed
                if action == 'remove':
                    handled_changes += 1
                    index = __find_package_name_index(packages, name)
                    if index:
                        del packages[index]
                elif action == 'new release':
                    handled_changes += 1
                    updated_pack_info = __get_package_info(name)
                    if updated_pack_info:
                        index = __find_package_name_index(packages, name)
                        if index:
                            packages[index] = updated_pack_info
                        else:
                            packages.append(updated_pack_info)
                else:
                    pass
            # write the new file with the updated package list
            __write_pypi_package_file(save_file, packages)
            __write_cached_changelog_last_serial(save_file, changelog_serial=changelog_serial)
        else:
            raise Exception("Can not update '%s'. File does not exist" % save_file)
    except Exception as e:
        sys.stderr.write("pypi file update for '%s' failed: %s.\n"
                         % (save_file, e))
    else:
        sys.stderr.write("pypi file update for '%s' successful. Handled %s changes\n" % (save_file, handled_changes))


def __get_package_list():
    """get a list with packages available on pypi"""
    client = xmlrpc.client.ServerProxy(XMLRPC_SERVER_PROXY)
    packages_list = client.list_packages()
    return packages_list


def __get_package_info(package):
    """get highest sdist package version for the given package name"""
    try:
        client = xmlrpc.client.ServerProxy(XMLRPC_SERVER_PROXY)
        releases = client.package_releases(package)
        if len(releases) > 0:
            for data in client.release_urls(package, releases[0]):
                if data['packagetype'] == 'sdist':
                    return PackageInfo('pypi', package, releases[0], data['url'])
    except Exception as e:
        sys.stderr.write("can not get information for package '%s': %s\n" % (package, e))
    # no sdist package version found.
    return None


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description='Get package version from pypi')
    parser.add_argument(
        '--save-file', default='versions-pypi',
        help='path to the file where the results will be written')
    parser.add_argument(
        '--log', default=sys.stderr,
        help='log file to use (default: stderr)')
    parser.add_argument(
        '--only-if-old', action='store_true', default=False,
        help='execute only if the pre-existing result file is older than 12 hours')


    args = vars(parser.parse_args())

    # check file age
    if os.path.exists(args['save_file']):
        if not os.path.isfile(args['save_file']):
            sys.stderr.write('Save file %s is not a regular file.\n' % args['save_file'])
            sys.exit(1)
        if args['only_if_old']:
            stats = os.stat(args['save_file'])
            # Quit if it's less than 12-hours old
            if time.time() - stats.st_mtime < 3600 * 12:
                sys.exit(2)

    try:
        if args['log'] != sys.stderr:
            sys_stderr = sys.stderr
            sys.stderr = open(args['log'], 'a')

        current_changelog = __get_cached_changelog_last_serial(args['save_file'])
        if current_changelog:
            __update_pypi_package_file(args['save_file'], current_changelog)
        else:
            __create_pypi_package_file(args['save_file'])
    finally:
        if args['log'] != sys.stderr:
            sys.stderr.close()
            sys.stderr = sys_stderr

    sys.exit(0)
