#!/usr/bin/env python
"""CLI for serialising metadata updates on an s3-hosted yum repository.
"""
import os
import sys
import time
import tempfile
import shutil
import optparse
import logging
import collections
import yum
import boto3
import subprocess
import pexpect
from botocore.errorfactory import ClientError

try:
    from urlparse import urlparse
except ImportError:
    from urllib.parse import urlparse

try:
    from rpm_s3.vendor.createrepo import createrepo
except ImportError:
    sys.path.append(os.getcwd())
    from rpm_s3.vendor.createrepo import createrepo
import rpm_s3

# Hack for creating s3 urls
urlparse.uses_relative.append('s3')
urlparse.uses_netloc.append('s3')

class LoggerCallback(object):
    def errorlog(self, message):
        logging.error(message)

    def log(self, message):
        message = message.strip()
        if message:
            logging.debug(message)


class S3Grabber(object):
    def __init__(self, baseurl, visibility, extra_options=None):
        logging.info('S3Grabber: %s', baseurl)
        base = urlparse.urlsplit(baseurl)
        self.baseurl = baseurl
        self.basepath = base.path.lstrip('/')
        self.s3 = get_client(extra_options)
        self.bucket = self.s3.Bucket(name=base.netloc)
        self.visibility = visibility


    def _path_exists(self, path):
        """Check to see if an object exists on S3"""
        object_info = None
        try:
          object_info = self.s3.ObjectSummary(self.bucket.name, path)
          object_last_modified = object_info.last_modified
        except ClientError as e:
            if e.response['Error']['Code'] == "404":
                return False
            else:
                raise e
        return object_info


    def check(self, url):
        if url.startswith(self.baseurl):
            url = url[len(self.baseurl):].lstrip('/')
        logging.info("checking if key exists: %s", os.path.join(self.basepath, url))
        return self._path_exists(os.path.join(self.basepath, url))

    def urlgrab(self, url, filename, **kwargs):
        logging.info('urlgrab: %s', filename)
        anobject = self.check(url)
        if not anobject:
            raise createrepo.grabber.URLGrabError(14, '%s not found' % url)
        logging.info('downloading: %s', anobject.key)
        self.bucket.download_file(anobject.key, filename)
        return filename

    def syncdir(self, dir, url):
        """Copy all files in dir to url, removing any existing keys."""
        base = os.path.join(self.basepath, url)
        existing_keys = self.bucket.objects.filter(Prefix=base)
        new_keys = []
        for filename in sorted(os.listdir(dir)):
            source = os.path.join(dir, filename)
            target = os.path.join(base, filename)
            key = self.s3.Object(self.bucket.name, target).put(
                Body=open(source, 'rb'))
            if self.visibility != "implied":
              object_acl = self.s3.ObjectAcl(self.bucket.name, target)
              acl_set_response = object_acl.put(ACL=self.visibility)
            new_keys.append(target)
            logging.info('uploading: %s from: %s', target, source)
        for someobject in existing_keys:
            if someobject.key not in new_keys:
                someobject.delete()
                logging.info('removing: %s', someobject.key)

    def upload(self, file, url):
        """Copy file to url."""
        target = os.path.join(self.basepath, url)
        logging.info("upload: %s to %s", (file, target))
        filename = os.path.basename(file)
        newobject = self.s3.Object(self.bucket.name, target).put(
            Body=open(file, 'rb'))
        if self.visibility != "implied":
          object_acl = self.s3.ObjectAcl(self.bucket.name, target)
          acl_set_response = object_acl.put(ACL=self.visibility)


class FileGrabber(object):
    def __init__(self, baseurl):
        logging.info('FileGrabber: %s', baseurl)
        self.basepath = urlparse.urlsplit(baseurl).path
        logging.info('basepath: %s', self.basepath)

    def urlgrab(self, url, filename, **kwargs):
        """Expects url of the form: file:///path/to/file"""
        filepath = urlparse.urlsplit(url).path
        realfilename = os.path.join(self.basepath, filepath)
        logging.info('fetching: %s', realfilename)
        # The filename name is REALLY important for createrepo as it derives
        # the base path from its own tempdir, etc.
        os.symlink(realfilename, filename)
        return filename


def get_client(options):
    if options.s3_endpoint_url:
      s3_resource = boto3.resource('s3',
                                   endpoint_url=options.s3_endpoint_url,
                                   config=boto3.session.Config(
                                       signature_version=options.s3_signature_version
                                   )
                                   )
    else:
      s3_resource = boto3.resource('s3')

    # TODO: introduce session
    return s3_resource

def sign_pexpect(rpmfile):
    """Requires a proper ~/.rpmmacros file. See <http://fedoranews.org/tchung/gpg/>"""
    cmd = "rpm --resign '%s'" % rpmfile
    logging.info(cmd)
    try:
        child = pexpect.spawn(cmd)
        child.expect('Enter pass phrase: ')
        child.sendline('')
        child.expect(pexpect.EOF)
    except pexpect.EOF as e:
        print("Unable to sign package '%s' - %s" % (rpmfile, child.before))
        logging.error("Unable to sign package: %s", e)
        exit(1)

def sign_agent(rpmfile):
    """Requires a proper ~/.rpmmacros file. See <http://fedoranews.org/tchung/gpg/>"""
    cmd = ["rpm", "--resign", rpmfile]
    logging.info(cmd)
    try:
        subprocess.check_call(cmd)
    except subprocess.CalledProcessError as e:
        print("Unable to sign package '%s'" % (rpmfile))
        logging.error("Unable to sign package: %s", e)
        exit(1)

def sign_metadata(repomdfile):
    """Requires a proper ~/.rpmmacros file. See <http://fedoranews.org/tchung/gpg/>"""
    cmd = ["gpg", "--detach-sign", "--armor", repomdfile]
    logging.info(cmd)
    try:
        subprocess.check_call(cmd)
        logging.info("Successfully signed repository metadata file")
    except subprocess.CalledProcessError as e:
        print("Unable to sign repository metadata '%s'" % (repomdfile))
        logging.error("Unable to sign repository metadata: %s", e)
        exit(1)

def setup_repository(repo, repopath):
    """Make sure a repo is present at repopath"""
    key = repo._grab.check("repodata/repomd.xml")
    if key:
        logging.info("Existing repository detected.")
    else:
        lib_root = os.path.dirname(rpm_s3.__file__)
        path_to_empty_repo = os.path.join(lib_root, "empty-repo", "repodata")
        logging.info("Empty repository detected. Initializing with empty repodata...")
        repo._grab.syncdir(path_to_empty_repo, "repodata")

def update_repodata(repopath, rpmfiles, options):
    logging.info('rpmfiles: %s', rpmfiles)
    tmpdir = tempfile.mkdtemp()
    s3base = urlparse.urlunsplit(('s3', options.bucket, repopath, '', ''))
    s3grabber = S3Grabber(s3base, options.visibility, options)
    filegrabber = FileGrabber("file://" + os.getcwd())

    # Set up temporary repo that will fetch repodata from s3
    yumbase = yum.YumBase()
    yumbase.preconf.disabled_plugins = '*'
    yumbase.conf.cachedir = os.path.join(tmpdir, 'cache')
    yumbase.repos.disableRepo('*')
    repo = yumbase.add_enable_repo('s3')
    repo._grab = s3grabber

    setup_repository(repo, repopath)

    # Ensure that missing base path doesn't cause trouble
    repo._sack = yum.sqlitesack.YumSqlitePackageSack(
        createrepo.readMetadata.CreaterepoPkgOld)

    # Create metadata generator
    mdconf = createrepo.MetaDataConfig()
    mdconf.directory = tmpdir
    mdgen = createrepo.MetaDataGenerator(mdconf, LoggerCallback())
    mdgen.tempdir = tmpdir

    # Combine existing package sack with new rpm file list
    new_packages = []
    for rpmfile in rpmfiles:
        rpmfile = os.path.realpath(rpmfile)
        logging.info("rpmfile: %s", rpmfile)

        if options.sign:
            if options.expect:
              sign_pexpect(rpmfile)
            else:
              sign_agent(rpmfile)

        mdgen._grabber = filegrabber
        # please, don't mess with my path in the <location> tags of primary.xml.gz
        relative_path = "."
        newpkg = mdgen.read_in_package("file://" + rpmfile, relative_path)
        mdgen._grabber = s3grabber

        # don't put a base url in <location> tags of primary.xml.gz
        newpkg._baseurl = None
        older_pkgs = yumbase.pkgSack.searchNevra(name=newpkg.name)

        # Remove older versions of this package (or if it's the same version)
        for i, older in enumerate(reversed(older_pkgs), 1):
            if i > options.keep or older.pkgtup == newpkg.pkgtup:
                yumbase.pkgSack.delPackage(older)
                logging.info('ignoring: %s', older.ui_nevra)
        ## The package is now removed if it has the same name
        ## If we passed -d true, then we don't want to add it back
        ## if we didn't then we wnat to include the package
        if options.delete is False:
            new_packages.append(newpkg)
    mdconf.pkglist = list(yumbase.pkgSack) + new_packages

    # Write out new metadata to tmpdir
    mdgen.doPkgMetadata()
    mdgen.doRepoMetadata()
    mdgen.doFinalMove()

    # Upload rpm files to destination
    for rpmfile in rpmfiles:
        rpmfile = os.path.realpath(rpmfile)
        logging.info("rpmfile: %s", rpmfile)
        s3grabber.upload(rpmfile, os.path.basename(rpmfile))


    # Generate repodata/repomd.xml.asc
    if options.sign:
        sign_metadata(os.path.join(tmpdir, 'repodata', 'repomd.xml'))

    # Replace metadata on s3
    s3grabber.syncdir(os.path.join(tmpdir, 'repodata'), 'repodata')

    shutil.rmtree(tmpdir)

def main(options, args):
    loglevel = ('WARNING', 'INFO', 'DEBUG')[min(2, options.verbose)]
    logging.basicConfig(
        filename=options.logfile,
        level=logging.getLevelName(loglevel),
        format='%(asctime)s %(levelname)s %(message)s',
    )

    update_repodata(options.repopath, args, options)


if __name__ == '__main__':
    parser = optparse.OptionParser()
    parser.add_option('-b', '--bucket', default='my-bucket')
    parser.add_option('-p', '--repopath', default='')
    parser.add_option('-k', '--keep', type='int', default=2)
    parser.add_option('-v', '--verbose', action='count', default=0)
    parser.add_option('--visibility', default='public-read')
    parser.add_option('-s', '--sign', action='count', default=0)
    parser.add_option('-e', '--expect', action='count', default=0)
    parser.add_option('-l', '--logfile')
    parser.add_option('-d', '--delete', action='store_true', default=False)
    parser.add_option('-r', '--region')
    parser.add_option('--s3_signature_version', default='')
    parser.add_option('--s3_endpoint_url', default='')
    options, args = parser.parse_args()
    if options.s3_endpoint_url and not options.s3_signature_version:
      raise Exception("If you override s3_endpoint_url you need to override s3_signature_version too (for minio it is s3v4)")
    main(options, args)

