#!/usr/bin/python3
#
# Copyright 2012 SUSE Linux
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

from __future__ import print_function
from __future__ import unicode_literals

import argparse
from contextlib import closing
from contextlib import contextmanager
from datetime import datetime
import hashlib
import io
import json
import os
import re
import shutil
import six
import subprocess
import sys
import tarfile
import tempfile
import requests
import zipfile
from six.moves import filter
from six.moves.urllib.parse import urlparse
import warnings


RPMSPEC_BIN = "/usr/bin/rpmspec"
OSC_BIN = "/usr/bin/osc"
GITHUB_CHANGES = ("https://api.github.com/repos/%(owner)s/%(repo)s/"
                  "compare/%(v1)s...%(v2)s")
OBS_SERVICE_DOWNLOAD_FILES = "/usr/lib/obs/service/download_files"


def _download_files_source_service():
    """call the download_files source service"""
    cmd_download_files = [OBS_SERVICE_DOWNLOAD_FILES,
                          '--outdir', '.']
    if not os.path.exists(OBS_SERVICE_DOWNLOAD_FILES):
        warnings.warn('%s not does not exist. Not calling.' % (
            OBS_SERVICE_DOWNLOAD_FILES))
        return
    try:
        subprocess.check_call(cmd_download_files)
    except OSError as e:
        warnings.warn('Can not call OBS source service '
                      '"download_files": %s' % (e))


@contextmanager
def _extract_archive_to_tempdir(archive_filename):
    """extract the given tarball or zipfile to a tempdir.
    Delete the tempdir at the end"""
    if not os.path.exists(archive_filename):
        raise Exception("Archive '%s' does not exist" % (archive_filename))

    tempdir = tempfile.mkdtemp(prefix="obs-service-renderspec_")
    try:
        if tarfile.is_tarfile(archive_filename):
            with tarfile.open(archive_filename) as f:
                f.extractall(tempdir)
        elif zipfile.is_zipfile(archive_filename):
            with zipfile.ZipFile(archive_filename) as f:
                f.extractall(tempdir)
        else:
            raise Exception("Can not extract '%s'. "
                            "Not a tar or zip file" % archive_filename)
        yield tempdir
    finally:
        shutil.rmtree(tempdir)


def _find_pbr_json(directory):
    """find and return the full path to a pbr.json file or None if not found"""
    for root, dirs, files in os.walk(directory):
        for filename in files:
            if filename == 'pbr.json':
                return os.path.join(root, filename)
    # no pbr.json file found
    return None


def _find_archives(directories, basename):
    """return a list of archives in the given directories
    or an empty list if no archive(s) can be found"""
    if isinstance(directories, six.string_types):
        directories = [directories]

    return sorted(
        [os.path.join(d, f) for d in directories if d for f in os.listdir(d)
         if f.startswith(basename) and
         f.endswith(('tar.gz', 'zip', 'tar.bz2', 'xz'))],
        key=lambda x: os.stat(x).st_ctime, reverse=True)


def _get_changelog_github(owner, repo, v1, v2):
    """get a list with changes for the given versions"""
    url = GITHUB_CHANGES % dict(owner=owner, repo=repo,
                                v1=v1, v2=v2)
    try:
        res = requests.get(url)
    except Exception as exc:
        print("Can not get '%s': %s" % (url, str(exc)))
        sys.exit("Can not get '%s': %s" % (url))
    if res.status_code != 200:
        raise Exception("Can not get changes from '%s'" % (url))
    data = res.json()
    commits = data.get('commits', [])
    changes = []
    for c in commits:
        msg = c['commit']['message'].splitlines()[0]
        if not msg.startswith('Merge '):
            changes += [msg]
    # remove duplicates
    return list(set(changes))


def _get_changelog(changelog_provider, v_old, v_new):
    """get changelog for the given versions"""
    changes = []
    if changelog_provider:
        provider, params = changelog_provider.split(',', 1)
        if provider == 'gh':
            owner, repo = params.split(',', 2)
            changes = _get_changelog_github(owner, repo, v_old, v_new)
        elif provider == 'none':
            changes = []
        else:
            raise Exception("Invalid changelog-provider '%s'" % (provider))
    return changes


def _get_changes_datetime():
    return datetime.utcnow().strftime('%a %b %e %T UTC %Y')


def _get_changes_string(changes, email):
    """get a .changes compatible string"""
    if not changes:
        return None
    # header
    changes_str = '-' * 67 + '\n'
    changes_str += '%s - %s\n\n' % (_get_changes_datetime(), email)
    for c in changes:
        if isinstance(c, six.string_types):
            # indent1
            changes_str += '- %s\n' % (c)
        else:
            # expect another list - indent2
            for cc in c:
                changes_str += '  - %s\n' % (cc)
    # footer
    changes_str += '\n'
    return changes_str


def _prepend_string_to_file(string, filename):
    """prepend content from string to filename"""
    with io.open(filename, encoding='utf-8', mode='r') as f:
        current = f.read()
    new = string + current
    with io.open(filename, encoding='utf-8', mode='w') as f:
        f.write(new)


def _get_version_from_pbr_json(archive_filename):
    """returns a git version sha or None if not found"""
    with _extract_archive_to_tempdir(archive_filename) as directory:
        pbr_json_file = _find_pbr_json(directory)
        if pbr_json_file:
            with open(pbr_json_file, 'r') as pbr_file:
                pbr_data = json.load(pbr_file)
                if 'git_version' in pbr_data and pbr_data['git_version']:
                    return pbr_data['git_version']
    return None


def _get_version_from_spec(specfile):
    """get a single version from the given .spec file"""
    if not os.path.exists(specfile):
        # maybe this is the initial convertion.
        return None
    version = subprocess.check_output(
        [RPMSPEC_BIN, '-q', '--queryformat', '%{VERSION}\n', specfile])
    return list(set(filter(None, version.decode("utf-8").split('\n'))))[0]


def _get_version(input_filename, output_filename):
    """return the version from pbr (or None) and the version from the
    .spec file (or None) in a tuple"""
    version_pbr = None
    version_spec = None
    # input_filename is the input_template parameter which might be something
    # like https://example/monasca-ui.spec.j2?h=stable/pike
    # we are just interested in the path
    input_filename = urlparse(input_filename).path
    # 1) try to get the version from the tarball
    basename = re.sub('.spec.j2$', '', os.path.basename(input_filename))
    archives = _find_archives(['.'], basename)
    for archive in archives:
        version = _get_version_from_pbr_json(archive)
        if version:
            version_pbr = version
            break
    # 2) try to get version from spec
    version_spec = _get_version_from_spec(output_filename)
    return version_pbr, version_spec


def _get_patch_sha256_from_patchname(patch_name):
    if not os.path.exists(patch_name):
        return None
    sha = hashlib.sha256()
    with open(patch_name, mode='rb') as f:
        sha.update(f.read())
        return sha.hexdigest()


def _get_patch_names_from_spec(specfile):
    """get the available patch names from a given spec file"""
    if not os.path.exists(specfile):
        # maybe this is the initial convertion.
        return []
    with open(specfile, 'r') as sf:
        content = sf.read()
        matches = re.findall(r'^(Patch\d*):\s*([^\s]+)', content,
                             re.MULTILINE | re.IGNORECASE)
        return matches


def _get_patches(specfile):
    patch_names = _get_patch_names_from_spec(specfile)
    patches = {}
    for p_number, p_name in patch_names:
        patches[p_name] = _get_patch_sha256_from_patchname(p_name)
    return patches


def _get_patches_changes(patches_old, patches_new):
    patches_old_names = set(patches_old.keys())
    patches_new_names = set(patches_new.keys())
    changes = {}
    # added
    added = patches_new_names - patches_old_names
    # removed
    removed = patches_old_names - patches_new_names
    # updated
    updated = []
    for p_name in (patches_old_names & patches_new_names):
        if patches_old[p_name] != patches_new[p_name]:
            updated.append(p_name)
    changes['added'] = list(added)
    changes['removed'] = list(removed)
    changes['updated'] = updated
    return changes


def _obs_add_remove_patches(patch_changes):
    if not os.path.exists(OSC_BIN) or not \
       os.path.isdir(os.path.join(os.getcwd(), '.osc')):
        return
    for p in patch_changes['removed']:
        osc_rm = [OSC_BIN, 'rm', '-f', p]
        with open(os.devnull, 'w') as devnull:
            subprocess.check_call(' '.join(osc_rm), stdout=devnull,
                                  stderr=subprocess.STDOUT, shell=True)
    for p in patch_changes['added']:
        osc_add = [OSC_BIN, 'add', p]
        with open(os.devnull, 'w') as devnull:
            subprocess.check_call(' '.join(osc_add), stdout=devnull,
                                  stderr=subprocess.STDOUT, shell=True)


def _obs_add_remove_version_source(old_spec_version, new_spec_version):
    """after the spec was updated and if a new version is available, remove via
    'osc' the old tarball and add the new tarball"""
    if not os.path.exists(OSC_BIN) or not \
       os.path.isdir(os.path.join(os.getcwd(), '.osc')):
        return

    if old_spec_version == new_spec_version:
        return

    extensions = ['tar.gz,', 'zip', 'tar.bz2', 'xz']
    osc_rm = [OSC_BIN, 'rm', '-f', '*-%s.{%s}' % (old_spec_version,
                                                  ','.join(extensions))]
    with open(os.devnull, 'w') as devnull:
        subprocess.call(' '.join(osc_rm), stdout=devnull,
                        stderr=subprocess.STDOUT, shell=True)

    osc_add = [OSC_BIN, 'add', '*-%s.{%s}' % (new_spec_version,
                                              ','.join(extensions))]
    with open(os.devnull, 'w') as devnull:
        subprocess.call(' '.join(osc_add), stdout=devnull,
                        stderr=subprocess.STDOUT, shell=True)


def download_file(url, dest):
    with closing(requests.get(url, stream=True)) as r:
        if r.status_code != 200:
            raise Exception("Can not get url '%s' (%s)" % (url, r.status_code))
        with open(dest, 'wb') as f:
            for chunk in r.iter_content(chunk_size=1024):
                if chunk:
                    f.write(chunk)


def _download_patches(input_template, patch_names):
    if input_template.startswith('http'):
        input_template_url = urlparse(input_template)
        dirpath = os.path.dirname(input_template_url.path)
        for p in patch_names:
            # we assume that the patch is next to the input template
            patch_path = os.path.join(dirpath, p[1])
            patch_url = input_template_url._replace(path=patch_path)
            download_file(patch_url.geturl(), p[1])
            print('Downloaded patch {} ({})'.format(p[1], p[0]))


def parse_args():
    parser = argparse.ArgumentParser(description='renderspec source service')
    parser.add_argument('--outdir',
                        help='osc service parameter that does nothing')
    parser.add_argument('--input-template',
                        help='.spec.j2 template file path')
    parser.add_argument('--output-name',
                        help='name of the rendered .spec.j2 file')
    parser.add_argument('--spec-style', default='suse',
                        help='Spec style to use. Default is "%(default)s"')
    parser.add_argument('--epochs', default=None,
                        help='epochs file path')
    parser.add_argument('--requirements', default=None,
                        help='requirements file path')
    parser.add_argument('--changelog-provider', default=None,
                        help='A provider to generate a changelog. '
                        'If not set, no changelog will be generated.')
    parser.add_argument('--changelog-email', default=None,
                        help='A email address used in the .changes file. ')
    return vars(parser.parse_args())


if __name__ == '__main__':
    args = parse_args()

    if not args['input_template']:
        sys.exit('input-template must be given and must exist')

    tmpdir = tempfile.mkdtemp(prefix='renderspec_tmp_')
    try:
        if args['input_template'].startswith('http'):
            input_filename = os.path.join(tmpdir, 'input_template')
            download_file(args['input_template'], input_filename)
        else:
            input_filename = args['input_template']
        if not os.path.exists(input_filename):
            sys.exit("input file '%s' does not exist" % (input_filename))

        if args['epochs']:
            if args['epochs'].startswith('http'):
                epochs_filename = os.path.join(tmpdir, 'epochs')
                download_file(args['epochs'], epochs_filename)
            else:
                epochs_filename = args['epochs']
            if not os.path.exists(epochs_filename):
                sys.exit("epochs file '%s' does not exist" % (epochs_filename))

        if args['requirements']:
            if args['requirements'].startswith('http'):
                requirements_filename = os.path.join(tmpdir, 'requirements')
                download_file(args['requirements'], requirements_filename)
            else:
                requirements_filename = args['requirements']
            if not os.path.exists(requirements_filename):
                sys.exit("requirements file '%s' does not exist" % (
                    requirements_filename))

        if args['output_name']:
            outfile = args['output_name']
        else:
            outfile = re.sub('\.j2$', '', os.path.basename(input_filename))  # noqa: W605,E501

        # get the version *before* we run renderspec
        old_version_pbr, old_version_spec = _get_version(
            args['input_template'], outfile)

        # get the patches *before* we run renderspec
        old_patches = _get_patches(outfile)

        cmd = ['renderspec', '--output', outfile,
               '--spec-style', args['spec_style']]
        if args['epochs']:
            cmd += ['--epochs', epochs_filename]
        if args['requirements']:
            cmd += ['--requirements', requirements_filename]

        cmd += [input_filename]
        # run renderspec
        subprocess.check_call(cmd)

        # run download_files service (for getting the git sha from pbr.json, we
        # need the new tarball. Otherwise we get the old version again)
        _download_files_source_service()

        # get the version *after* we run renderspec
        new_version_pbr, new_version_spec = _get_version(
            args['input_template'], outfile)

        # download patches
        new_patches_names = _get_patch_names_from_spec(outfile)
        _download_patches(args['input_template'], new_patches_names)

        # get the patches *after* we run renderspec and downloaded the patches
        new_patches = _get_patches(outfile)

        # check for changelog generation
        changes_file = outfile.replace('.spec', '.changes')
        if os.path.exists(changes_file):
            changes = []
            # changes for patch add/remove/update
            patch_changes = _get_patches_changes(old_patches, new_patches)
            for p in patch_changes['added']:
                changes.append('added {}'.format(p))
            for p in patch_changes['removed']:
                changes.append('removed {}'.format(p))
            for p in patch_changes['updated']:
                changes.append('updated {}'.format(p))

            # changes for version update
            if old_version_spec != new_version_spec:
                print("Version changed: '%s (%s)' -> '%s (%s)'"
                      % (old_version_spec, old_version_pbr,
                         new_version_spec, new_version_pbr))
                changes.append('update to version %s' % new_version_spec)
                if old_version_pbr and new_version_pbr:
                    changes_new_version = _get_changelog(
                        args['changelog_provider'],
                        old_version_pbr, new_version_pbr)
                else:
                    changes_new_version = _get_changelog(
                        args['changelog_provider'],
                        old_version_spec, new_version_spec)
                changes.append(changes_new_version)
            if changes:
                changes_str = _get_changes_string(changes,
                                                  args['changelog_email'])
                print("Updated %s" % (changes_file))
                _prepend_string_to_file(changes_str, changes_file)
                # remove/add old/new tarball from OBS
                _obs_add_remove_version_source(old_version_spec,
                                               new_version_spec)
                # remove/add patches from OBS
                _obs_add_remove_patches(patch_changes)
        else:
            print('{} does not exist. Not generating any changes entries'.
                  format(changes_file))
    finally:
        shutil.rmtree(tmpdir)
