#!/usr/bin/python
# -*- coding: utf-8 -*-
# vim: ts=4 et sw=4 sts=4 ai sta:

"""
This script creates linked project in OBS and waits
until all builds are finished.

Author: Ed Bartosh <eduard.bartosh@intel.com>
Licence: MIT
"""

import sys
import os
import tempfile
import argparse
import hashlib
import time
import urllib2

from collections import defaultdict
from datetime import date
from functools import wraps
from urllib import quote_plus, pathname2url
import M2Crypto
from M2Crypto.SSL.Checker import SSLVerificationError
from ssl import SSLError
from xml.dom import minidom

import osc.conf
import osc.core

PRJ_TEMPLATE = """
<project name="%(target)s">
  <title/>
  <description/>
  <link project="%(src)s"/>
  <person role="maintainer" userid="%(user)s"/>
"""

REPO_TEMPLATE = """
  <repository name="%(name)s" linkedbuild="localdep">
    <path repository="%(name)s" project="%(src)s"/>
"""

FILE_TEMPLATE = """
<entry md5="%(md5)s" name="%(fname)s"/>
"""

class BuildError(Exception):
    """Custom exception for this script."""
    pass

class CancelRetryError(Exception):
    """Exception for handling cancelling of the re_try loop. Needed for
    transparently re-raising the previous exception."""
    def __init__(self):
        self.typ, self.val, self.backtrace = sys.exc_info()


def re_try(exceptions, tries=3, sleep=0):
    """Decorator for re-trying function calls"""
    def decorator(func):
        """The "real" decorator function"""
        @wraps(func)
        def wrap(*args, **kwargs):
            """Wrapper for re-trying func"""
            for attempt in range(1, tries + 1):
                try:
                    return func(*args, **kwargs)
                except CancelRetryError as err:
                    raise err.typ, err.val, err.backtrace
                except exceptions as err:
                    if attempt >= tries:
                        raise
                    elif sleep:
                        time.sleep(sleep)
        return wrap
    return decorator

def parse_cmdline(argv):
    """Parse commandline with argparse."""
    parser = argparse.ArgumentParser(description="Submit package to OBS")
    parser.add_argument("--sproject")
    parser.add_argument("--tproject", required=True)
    parser.add_argument("--package")
    parser.add_argument("paths", nargs='*')
    parser.add_argument("--timeout", type=int, default=15)
    parser.add_argument("--wait", action='store_true')
    options = parser.parse_args(argv)
    if not options.wait and (not options.paths or not options.package):
        parser.error("either --wait or --package and paths "
                     "have to be specified")
    return options

def link_project(apiurl, src, target):
    """Creates linked(target) project based on existing
       original(src) project.
    """
    # generate configuration for target project based on
    # configuration of source project
    targetmeta = PRJ_TEMPLATE % {'target': target, 'src': src,
                                 'user': osc.conf.config['user']}
    repos = defaultdict(list)
    for repo in osc.core.get_repos_of_project(apiurl, src):
        repos[repo.name].append(repo.arch)
    for name in repos:
        targetmeta += REPO_TEMPLATE % {'name': name, 'src': src}
        for arch in repos[name]:
            targetmeta += "<arch>%s</arch>\n" % arch
        targetmeta += "</repository>\n"
    targetmeta += "</project>\n"

    put_meta(apiurl, "prj", quote_plus(target), targetmeta)

    # Set release tag in prjconf so that the rpm versions in target project
    # will be greater than those in the base project
    prjconf = "Release: %s.<CI_CNT>\n" % date.today().strftime('%Y%m%d')
    put_meta(apiurl, "prjconf", quote_plus(target), prjconf)


def branch_package(apiurl, src_project, src_package,
                   target_project, target_package=None):
    """Branch package from source to target project."""
    return osc.core.branch_pkg(apiurl, src_project, src_package,
                               target_project=target_project,
                               target_package=target_package)

def create_package(apiurl, prj, pkg):
    """Create package in the project."""
    meta = '<package project="%s" name="%s">'\
           '<title/><description/></package>' % (prj, pkg)
    put_meta(apiurl, "pkg", (quote_plus(prj), quote_plus(pkg)), meta)

def copy_package_meta(apiurl, src_prj, src_pkg, tgt_prj, tgt_pkg,
                      sections=None):
    """Copy package meta"""
    src_path = (quote_plus(src_prj), quote_plus(src_pkg))
    tgt_path = (quote_plus(tgt_prj), quote_plus(tgt_pkg))
    copy_meta(apiurl, 'pkg', src_path, tgt_path, sections)

@re_try(urllib2.HTTPError)
def get_meta(apiurl, metatype, path_args):
    """Get meta as a Document object"""
    url = osc.core.make_meta_url(metatype, path_args, apiurl)
    fobj = osc.core.http_GET(url)
    return minidom.parse(fobj)

def put_meta(apiurl, metatype, path_args, data):
    """Wrapper to put metadata to OBS."""
    url = osc.core.make_meta_url(metatype, path_args, apiurl, False)
    fileh, filename = tempfile.mkstemp(prefix="osc_metafile.",
                                       suffix=".xml", text=True)
    os.write(fileh, data)
    os.close(fileh)

    # Let's make 3 attempts to send the query, because sometimes
    # it failes with urllib2.HTTPError: HTTP Error 500: Internal Server Error
    @re_try(urllib2.HTTPError)
    def _call_put(*args, **kwargs):
        try:
            osc.core.http_PUT(*args, **kwargs)
        except urllib2.HTTPError as err:
            if err.code != 500:
                raise CancelRetryError
            else:
                raise
    _call_put(url, file=filename)

    os.unlink(filename)

def copy_meta(apiurl, metatype, src_path_args, tgt_path_args, sections=None):
    """Copy selected meta sections from source to target on the remote"""
    src_meta = get_meta(apiurl, metatype, src_path_args)
    tgt_meta = get_meta(apiurl, metatype, tgt_path_args)

    # Remove selected "sections" from the tgt meta and replace with one
    # from the src meta. We only consider "top-level" elements here (i.e. no
    # recursion in the xml tree). Copy all sections if none are specified.
    for child in tgt_meta.firstChild.childNodes[:]:
        if not sections or child.localName in sections:
            tgt_meta.firstChild.removeChild(child)
    for child in src_meta.firstChild.childNodes:
        if not sections or child.localName in sections:
            tgt_meta.firstChild.appendChild(child.cloneNode(True))

    # Write modified tgt meta back to server
    put_meta(apiurl, metatype, tgt_path_args, tgt_meta.toxml())

def hexdigest(fhandle, block_size=4096):
    """Calculates hexdigest of file content."""
    md5obj = hashlib.new('md5')
    while True:
        data = fhandle.read(block_size)
        if not data:
            break
        md5obj.update(data)
    return md5obj.hexdigest()

def add_files(apiurl, project, package, files):
    """Commits files to OBS."""
    query = {'cmd'    : 'commitfilelist',
             'user'   : osc.conf.get_apiurl_usr(apiurl),
             'keeplink': 1}
    url = osc.core.makeurl(apiurl, ['source', project, package], query=query)

    xml = "<directory>"
    for fpath in files:
        with open(fpath) as fhandle:
            xml += FILE_TEMPLATE % {"fname": os.path.basename(fpath),
                                    "md5": hexdigest(fhandle)}
    xml += "</directory>"

    osc.core.http_POST(url, data=xml)
    for fpath in files:
        put_url = osc.core.makeurl(
            apiurl, ['source', project, package,
                     pathname2url(os.path.basename(fpath))],
            query="rev=repository")
        osc.core.http_PUT(put_url, file=fpath)
    osc.core.http_POST(url, data=xml)

def exists(apiurl, prj, pkg=''):
    """Check if project or/and package exists."""

    if not prj:
        return False

    path_args = [quote_plus(prj)]

    exceptions = (urllib2.URLError, M2Crypto.SSL.SSLError, urllib2.HTTPError)
    @re_try(exceptions, sleep=3)
    def _call_meta_exists(*args, **kwargs):
        try:
            osc.core.meta_exists(*args, **kwargs)
            if pkg:
                return pkg in osc.core.meta_get_packagelist(apiurl, prj)
        except urllib2.HTTPError, err:
            if err.code == 404:
                return False
            raise
        except SSLVerificationError:
            raise BuildError("SSL verification error.")
        return True
    try:
        return _call_meta_exists(metatype='prj', path_args=tuple(path_args),
                                 create_new=False, apiurl=apiurl)
    except exceptions as err:
        raise BuildError("can't check if %s/%s exists: %s" % (prj, pkg, err))

def delete_project(apiurl, prj, force=False, msg=None):
    """Delete OBS project."""
    query = {}
    if force:
        query['force'] = "1"
    if msg:
        query['comment'] = msg
    url = osc.core.makeurl(apiurl, ['source', prj], query)

    @re_try(urllib2.HTTPError)
    def _call_delete(*args, **kwargs):
        osc.core.http_DELETE(*args, **kwargs)
    try:
        _call_delete(url)
    except urllib2.HTTPError as err:
        raise BuildError("can't delete project %s: %s" % (prj, err))

def build_published(status_line):
    """Parse status line and check if project buid is published."""
    for item in status_line.split(';'):
        if '/' in item and item.split('/')[2] != 'published':
            return False
    return True

def build(apiurl, project, package, timeout):
    """Wait until build is successfully finished or failed."""
    # waiting for project and package to appear in OBS
    while not exists(apiurl, project, package):
    #    print "build 11111111 timeout: %s" % timeout
    #    sys.stdout.flush()
        time.sleep(timeout)

    while True:
        # waiting to build results to appear
        while True:
            # Let's make 3 attempts to get results, because sometimes it failes
            # with cElementTree.ParseError: no element found: line 1, column 0
            exceptions = (osc.core.ET.ParseError, SSLError)
            @re_try(exceptions)
            def _call_get_prj_results(*args, **kwargs):
                return osc.core.get_prj_results(*args, **kwargs)
            try:
                results = _call_get_prj_results(apiurl, project,
                                                hide_legend=True, csv=True)
         #       print "results: %s" % results
         #       sys.stdout.flush()
            except exceptions:
                raise BuildError("error getting build results for %s" % project)

            if results and len(results) > 1 and build_published(results[0]):
                break
            print 'waiting for published build'
            time.sleep(timeout)

        statuses = []
        for pkginfo in results[1:]:
            splitted = pkginfo.split(';')
            pkg_status = [status for status in splitted[1:]
                            if status not in ('excluded', 'succeeded',
                                              'disabled', 'failed',
                                              'unresolvable')]
            statuses.extend(pkg_status)
            package = splitted[0]
            if [st for st in pkg_status if st not in ('building', 'scheduled',
                                                      'dispatching', 'finished',
                                                      'blocked', 'broken')]:
                raise BuildError('package %s is in unknown state: %s' % \
                                 (package, splitted[1:]))
            url = osc.core.makeurl(apiurl, ('source', project, package))
            if 'broken' in pkg_status:
                @re_try(urllib2.HTTPError)
                def _call_http_get(*args, **kwargs):
                    return osc.core.http_GET(*args, **kwargs)
                try:
                    state = minidom.parse(_call_http_get(url))
                except urllib2.HTTPError:
                    raise BuildError('unable to get source listing for %s' %
                                     package)
                infos = state.firstChild.getElementsByTagName('serviceinfo')
                if infos:
                    info = infos[0]
                    if info.getAttribute('code') == 'failed':
                        error = info.getElementsByTagName('error')[0]
                        print "OBS source service failed:"
                        print error.childNodes[0].nodeValue
                        return 1

            print 'waiting for %s: %s' % (package, splitted[1:])

        if 'failed' in splitted[1:]:
            print "OBS build failed in project=%s package=%s" %(project, package)
            return 1

        if not statuses:
            return 0

        time.sleep(timeout)


def main(argv):
    """Script entry point."""

    # get parameters from command line and environment
    #print "00000000000000000000"
    #sys.stdout.flush()
    params = parse_cmdline(argv[1:])

    sproject, tproject, package, timeout, paths = params.sproject, \
        params.tproject, params.package, params.timeout, params.paths

    # parse osc config
    osc.conf.get_config()
    apiurl = osc.conf.config['apiurl']

    if params.wait:
     #   print "55555555555555555555"
     #   sys.stdout.flush()
        # for restarted build project and package already exist
        # wait for sources reupload
        time.sleep(2*timeout)
    else:
        print "package %s : source project %s, target project %s" % \
              (package, sproject, tproject)

        sys.stdout.flush()
        if sproject and not exists(apiurl, sproject):
            raise BuildError("Source project %s doesn't exist" % sproject)

        if sproject:
            # !!! this is dangerous when used incorrectly as it can
            # remove target repo
            if exists(apiurl, tproject):
                print "linked project %s already exists, deleting" % tproject
                delete_project(apiurl, tproject)

            print "linking project %s to %s" % (sproject, tproject)
            link_project(apiurl, sproject, tproject)

        if not exists(apiurl, tproject, package):
            print "create package %s/%s" % (tproject, package)
            create_package(apiurl, tproject, package)

            if sproject and exists(apiurl, sproject, package):
                print "copy pkg meta from %s/%s to %s/%s" % (sproject, package,
                                                             tproject, package)
                copy_package_meta(apiurl, sproject, package, tproject, package)

        print "uploading files to %s/%s" % (tproject, package)
        sys.stdout.flush()
        add_files(apiurl, tproject, package, paths)


    print "project %s: waiting for the build results apiurl:%s  package: %s timeout:%s" % (tproject, apiurl, package, timeout)
    sys.stdout.flush()
    return build(apiurl, tproject, package, timeout)

if __name__ == "__main__":
    try:
        sys.exit(main(sys.argv))
    except BuildError, error:
        print "build failed: %s" % str(error)
    except Exception:
        import traceback
        print "Uncatched Exception: "
        print traceback.format_exc()
    sys.exit(1)
