#!/usr/bin/python3
#
# Copyright 2012-2013 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.

import argparse
import datetime
import glob
import os
import re
import shutil
import subprocess
import sys


OSC_EMAIL_RE = re.compile(r"\s*email\s*=\s*(.*)$")

QUILT_SETUP_PATCHES_RE = re.compile(r".*### rpmbuild: tp+")
QUILT_SETUP_SUCCESS_RE = re.compile(r".*Unpacking archive .*$")
QUILT_PUSH_FUZZ_RE = re.compile(r".*Hunk #\d+ succeeded at \d+ with fuzz \d+.*\..*Now at patch (.*)$")
QUILT_PUSH_OFFSET_RE = re.compile(r".*Hunk #\d+ succeeded at \d+ .*\..*Now at patch (.*)$")
QUILT_PUSH_SUCCESS_RE = re.compile(r".*Now at patch (.*)$")
QUILT_PUSH_ERROR_RE = re.compile(r".*Patch .* does not apply \(enforce with -f\)$")
QUILT_PUSH_REVERSE_RE = re.compile(r".*Patch (?:patches/)?(.*) can be reverse-applied$")
QUILT_REFRESH_SUCCESS_RE = re.compile(r".*Refreshed patch (.*)")
QUILT_SUCCESS_RE = re.compile(r".*File series fully applied, ends at (.*)$")
QUILT_SERIES_EMPTY_RE = re.compile(r".*No patches in series$")

SPEC_PATCH_PREAMBLE_RE_TEMPLATE = r"Patch(\d+):\s*{0}"  # Needs patch name, group(1) returns patch number
SPEC_PATCH_PRE_SECTION_RE_TEMPLATE = r"%patch{0}\s+.*"  # Needs patch number, ^

PATCH_ENDINGS = ["patch", "diff", "dif"]


class QuiltException(Exception):
    pass


def is_patch(filename):
    parts = filename.rsplit(".", 1)
    if len(parts) == 2:  # there is actually an ending
        if parts[1] in PATCH_ENDINGS:
            return True
    return False


def silent_popen(args, **kwargs):
    """Wrapper for subprocess.Popen with suppressed output.

    STERR is redirected to STDOUT which is piped back to the
    calling process and returned as the result.
    """
    return subprocess.Popen(args,
                            stderr=subprocess.STDOUT,
                            stdout=subprocess.PIPE, **kwargs).communicate()[0]

def get_dirs(dir):
    return [f for f in os.listdir(dir) if os.path.isdir(os.path.join(dir, f))]

def generate_changes_entry(args, basename, refreshed_patches=[], dropped_patches=[]):
    if not args.changesauthor:
        try:
            with open(os.path.expanduser("~/.oscrc"), "r") as oscrc:
                for line in oscrc.readlines():
                    match = OSC_EMAIL_RE.match(line)
                    if match:
                        args.changesauthor = match.groups(1)[0]
                        break
        except IOError:
            pass
    if not args.changesauthor:
        args.changesauthor = "opensuse-packaging@opensuse.org"

    write_changes = False
    timestamp = datetime.datetime.utcnow().strftime("%a %b %d %H:%M:%S UTC %Y")
    changes_header = """-------------------------------------------------------------------
{0} - {1}

""".format(timestamp, args.changesauthor)
    changes = "- Rebased patches:\n"

    if refreshed_patches:
        for patch in refreshed_patches:
            if args.ignorefuzz:
                write_changes = True
                changes += "  + {0} (offset / fuzz)\n".format(patch)

    if dropped_patches:
        write_changes = True
        for patch in dropped_patches:
            changes += "  + {0} dropped (merged upstream)\n".format(patch)

    # Check with osc for otherwise modified patches:
    otherwise_modified_patches = []
    try:
        for line in silent_popen(["osc", "status"]).split("\n"):
            if line.startswith("M "):  # Something got modified
                filename = line[1:].strip()
                if is_patch(filename) and filename not in refreshed_patches:
                    otherwise_modified_patches.append(filename)
    except OSError:
        pass  # No osc installed, sad thing

    if otherwise_modified_patches:
        write_changes = True
        for patch in otherwise_modified_patches:
            changes += "  + {0} (manually)\n".format(patch)

    if write_changes:
        changes += "\n"
        with open("{0}.changes".format(basename), "r+") as f:
            old_content = f.read()
            f.seek(0)
            # Check if we didn't write the same content during the previous run:
            old_changes = []
            for line in old_content.split("\n")[3:]:
                if line.startswith("----"):
                    break
                old_changes.append(line)
            # Now we parsed the previous changes entry, compare with this one...
            if "\n".join(old_changes)+"\n" == changes:
                return
            f.write(changes_header + changes + old_content)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Refresh Patches")
    parser.add_argument("--changesgenerate", help="Whether or not to generate changes file entries from SCM commit log since a given parent revision (see changesrevision).  Default is 'disable'.")
    parser.add_argument("--changesauthor", help="The author of the changes file entry to be written, defaults to first email entry in ~/.oscrc or \"opensuse-packaging@opensuse.org\" if there is no .oscrc found.")
    parser.add_argument("--ignorefuzz", help="The service stops when 'quilt patch' only works with fuzz. Set this flag to 'enable' if you want to refresh patches regardless of fuzz.")
    parser.add_argument('--outdir',
                        help='osc service parameter that does nothing')
    args = parser.parse_args()

    # Switch to C locale since we're parsing output of localized commands
    os.environ['LC_ALL'] = 'C'
    os.environ['LANG'] = 'C'

    # Check if there are patch files present, otherwise the service doesn't need to run:
    have_patches = False
    for filename in os.listdir("."):
        if is_patch(filename):
            have_patches = True

    if have_patches:
        dropped_patches = []
        for specfile in glob.glob('*.spec'):
            basename = specfile.rsplit('.spec')[0]

            # There's no other way to find out what directory-name the tarball will be unpacked to
            # rather than to diff the directory before and after invoking quilt (which invokes tar).
            # Well, we could check the tarball contents for the top-most directory, but that's not any better...
            src_dir_contents_pre_tar = get_dirs(".")

            output = silent_popen(["quilt", "setup", specfile])
            output_oneline = output.replace("\n", "")
            match = QUILT_SETUP_SUCCESS_RE.match(output_oneline)
            if not match:
                print("quilt setup failed:\n{0}".format(output))
                sys.exit(1)

            # Now let's find out what we actually untarred to...
            src_dir_contents_post_tar = get_dirs(".")
            for d in src_dir_contents_post_tar:
                if d not in src_dir_contents_pre_tar:
                    quilt_dir = d  # Found it!
                    break;

            match = QUILT_SETUP_PATCHES_RE.match(output_oneline)
            if not match:
                shutil.rmtree(quilt_dir)  # Cleanup to be prepared for next quilt run
                continue

            refreshed_patches = []
            try:
                src_dir = os.getcwd()
                os.chdir(quilt_dir)
                while True:
                    output = silent_popen(["quilt", "push"])
                    output_oneline = output.replace("\n", "")

                    match = QUILT_SUCCESS_RE.match(output_oneline)
                    if match:  # We're done
                        print("Finished refreshing patches for {0}".format(specfile))
                        break
                    match = QUILT_SERIES_EMPTY_RE.match(output_oneline)
                    if match:  # Either we removed all dropped patches from 'series' or ...
                        print("Finished refreshing patches for {0}".format(specfile))
                        break
                    match = QUILT_PUSH_FUZZ_RE.match(output_oneline)
                    if match:  # Manual intervention needed
                        if args.ignorefuzz != "enable":
                            raise QuiltException(output)
                    match = QUILT_PUSH_OFFSET_RE.match(output_oneline)
                    if match:  # Oh, got something to refresh
                        patch_name = os.path.basename(match.groups(1)[0])
                        #print("Patch {0} refreshed".format(patch_name))
                        output2 = silent_popen(["quilt", "refresh"])
                        match2 = QUILT_REFRESH_SUCCESS_RE.match(output2)
                        if not match2:  # It didn't work
                            raise QuiltException("Patch {0} refresh failed:\n{0}".format(patch_name, output2))
                        refreshed_patches.append(patch_name)
                        continue
                    match = QUILT_PUSH_REVERSE_RE.match(output_oneline)
                    if match:  # Patch was fully merged, mark it for dropping
                        patch_name = os.path.basename(match.groups(1)[0])
                        # Only remove the patch from the 'series' file to be able to go on with further
                        # patches. Delete fully-merged patches only after all spec files have been processed.
                        with open("series", "r") as f:
                            lines = f.readlines()
                        with open("series", "w") as f:
                            for line in lines:
                                if line == patch_name + "\n":
                                    continue
                                f.write(line)
                        # Let's try to remove it from the spec file too.
                        with open(os.path.join(src_dir, specfile), "r") as f:
                            lines = f.readlines()
                        with open(os.path.join(src_dir, specfile), "w") as f:
                            patch_number = None
                            for line in lines:
                                # Try to kill the preamble line first and return the patch number
                                match = re.match(SPEC_PATCH_PREAMBLE_RE_TEMPLATE.format(patch_name), line)
                                if match:
                                    patch_number = match.groups(1)[0]
                                    continue
                                if patch_number:  # Use the patch number to find the %pre section line...
                                    match = re.match(SPEC_PATCH_PRE_SECTION_RE_TEMPLATE.format(patch_number), line)
                                    if match:
                                        continue
                                f.write(line)
                        dropped_patches.append(patch_name)
                        continue
                    match = QUILT_PUSH_SUCCESS_RE.match(output_oneline)
                    if match:  # Patch applied as is
                        #print("Patch {0} ok".format(match.groups(1)[0]))
                        continue
                    match = QUILT_PUSH_ERROR_RE.match(output_oneline)
                    if match:  # Manual intervention needed
                        raise QuiltException(output)
                if args.changesgenerate == "enable":
                    os.chdir(src_dir)
                    generate_changes_entry(args, basename, refreshed_patches, dropped_patches)
                    os.chdir(quilt_dir)
            except QuiltException as e:
                print(e)
                sys.exit(1)
            finally:
                os.chdir(src_dir)
                shutil.rmtree(quilt_dir)

        for dropped_patch in dropped_patches:
            print("Patch {0} merged upstream and dropped".format(patch_name))
            try:
                silent_popen(["osc", "rm", patch_name])
            except OSError:  # No osc installed, let's still kill the file:
                os.remove(os.path.join(src_dir, patch_name))
