#!/usr/bin/python
# -*- coding: utf-8 -*-
# vim: ts=4 et sw=4 sts=4 ai sta:
#
# Copyright (c) 2013, 2014, 2015 Intel, Inc.
#
# 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; version 2 of the License
#
# This program 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 General Public License
# for more details.

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

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

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.m2urllib2.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):
        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)
            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
    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:
        # 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)

        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)
        add_files(apiurl, tproject, package, paths)


    print "project %s: waiting for the build results" % tproject
    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)
