#!/usr/bin/python3
# Copyright (c) 2016-2017, Ximin Luo <infinity0@debian.org>
#
# 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; either version 3
# of the License, or (at your option) any later version.
#
# 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.
#
# See file /usr/share/common-licenses/GPL-3 for more details.
#

# pylint: disable=invalid-name
# pylint: enable=invalid-name

"""
Apply a debdiff to a Debian source package.

It handles d/changelog hunks specially, to avoid conflicts.

Depends on dpkg-dev, devscripts, python3-unidiff, quilt.
"""

import argparse
import email.utils
import hashlib
import logging
import os
import shutil
import subprocess
import sys
import tempfile
import time

try:
    import unidiff
except ImportError:
    print(
        "Please install 'python3-unidiff' in order to use this utility.",
        file=sys.stderr,
    )
    sys.exit(1)
from debian.changelog import ChangeBlock, Changelog

# this can be any valid value, it doesn't appear in the final output
DCH_DUMMY_TAIL = (
    "\n -- debdiff-apply dummy tool <infinity0@debian.org>  "
    "Thu, 01 Jan 1970 00:00:00 +0000\n\n"
)
CHBLOCK_DUMMY_PACKAGE = "debdiff-apply PLACEHOLDER"
TRY_ENCODINGS = ["utf-8", "latin-1"]
DISTRIBUTION_DEFAULT = "experimental"


def workaround_dpkg_865430(dscfile, origdir, stdout):
    filename = subprocess.check_output(["dcmd", "--tar", "echo", dscfile]).rstrip()
    if not os.path.exists(
        os.path.join(origdir.encode("utf-8"), os.path.basename(filename))
    ):
        subprocess.check_call(["dcmd", "--tar", "cp", dscfile, origdir], stdout=stdout)


def is_dch(path):
    dirname = os.path.dirname(path)
    return (
        os.path.basename(path) == "changelog"
        and os.path.basename(dirname) == "debian"
        and os.path.dirname(os.path.dirname(dirname)) == ""
    )


def hunk_lines_to_str(hunk_lines):
    return "".join(map(lambda x: str(x)[1:], hunk_lines))


def read_dch_patch(dch_patch):
    if len(dch_patch) > 1:
        raise ValueError(
            "don't know how to deal with debian/changelog patch "
            "that has more than one hunk"
        )
    hunk = dch_patch[0]
    source_str = hunk_lines_to_str(hunk.source_lines()) + DCH_DUMMY_TAIL
    target_str = hunk_lines_to_str(hunk.target_lines())
    # here we assume the debdiff has enough context to see the previous version
    # this should be true all the time in practice
    source_version = str(Changelog(source_str, 1)[0].version)
    target = Changelog(target_str, 1)[0]
    return source_version, target


def apply_dch_patch(source_file, current, old_version, target, dry_run):
    target_version = str(target.version)

    if not old_version or not target_version.startswith(old_version):
        logging.warning(
            "don't know how to rebase version-change (%s => %s) onto %s",
            old_version,
            target_version,
            old_version,
        )
        newlog = subprocess.getoutput("EDITOR=cat dch -n 2>/dev/null").rstrip()
        version = str(Changelog(newlog, 1)[0].version)
        logging.warning(
            "using version %s based on `dch -n`; feel free to make me smarter", version
        )
    else:
        version_suffix = target_version[len(old_version) :]
        version = str(current[0].version) + version_suffix
        logging.info("using version %s based on suffix %s", version, version_suffix)

    if dry_run:
        return version

    current._blocks.insert(0, target)  # pylint: disable=protected-access
    current.set_version(version)

    shutil.copy(source_file, source_file + ".new")
    try:
        # disable unspecified-encoding, as in Mattia's opinion this should
        # likely be rewritten to use pure binary instead of encode/decode.
        # pylint: disable=unspecified-encoding
        with open(source_file + ".new", "w") as fp:
            current.write_to_open_file(fp)
        os.rename(source_file + ".new", source_file)
    except Exception:
        logging.warning("failed to patch %s", source_file)
        logging.warning("half-applied changes in %s", source_file + ".new")
        logging.warning("current working directory is %s", os.getcwd())
        raise
    return version


def call_patch(patch_str, *args, check=True, **kwargs):
    return subprocess.run(
        ["patch", "-p1"] + list(args),
        input=patch_str,
        universal_newlines=True,
        check=check,
        **kwargs,
    )


def check_patch(patch_str, *args, **kwargs):
    patch = call_patch(
        patch_str,
        "--dry-run",
        "-f",
        "--silent",
        *args,
        check=False,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        **kwargs,
    )
    return patch.returncode == 0


def debdiff_apply(patch, patch_name, args):
    # don't change anything if...
    dry_run = args.target_version or args.source_version

    changelog = list(filter(lambda x: is_dch(x.path), patch))
    if not changelog:
        logging.info("no debian/changelog in patch: %s", args.patch_file)
        old_version = None
        target = ChangeBlock(
            package=CHBLOCK_DUMMY_PACKAGE,
            author=f"{os.getenv('DEBFULLNAME')} <{os.getenv('DEBEMAIL')}>",
            date=email.utils.formatdate(time.time(), localtime=True),
            version=None,
            distributions=args.distribution,
            urgency="low",
            changes=["", f"  * Rebase patch {patch_name}.", ""],
        )
        target.add_trailing_line("")
    elif len(changelog) > 1:
        raise ValueError("more than one debian/changelog patch???")
    else:
        patch.remove(changelog[0])
        old_version, target = read_dch_patch(changelog[0])

    if args.source_version:
        if old_version:
            print(old_version)
        return False

    # read this here so --source-version can work even without a d/changelog
    with open(args.changelog, encoding="utf8") as fp:
        current = Changelog(fp.read())
    if target.package == CHBLOCK_DUMMY_PACKAGE:
        target.package = current[0].package

    if not dry_run:
        patch_str = str(patch)
        if check_patch(patch_str, "-N"):
            call_patch(patch_str)
            logging.info("patch %s applies!", patch_name)
        elif check_patch(patch_str, "-R"):
            logging.warning("patch %s already applied", patch_name)
            return False
        else:
            call_patch(patch_str, "--dry-run", "-f")
            raise ValueError(f"patch {patch_name} doesn't apply!")

    # only apply d/changelog patch if the rest of the patch applied
    new_version = apply_dch_patch(args.changelog, current, old_version, target, dry_run)
    if args.target_version:
        print(new_version)
        return False

    if args.repl:
        import code  # pylint: disable=import-outside-toplevel

        code.interact(local=locals())

    return True


def parse_args(args):
    parser = argparse.ArgumentParser(
        description="Apply a debdiff to a Debian source package"
    )
    parser.add_argument(
        "-v", "--verbose", action="store_true", help="Output more information"
    )
    parser.add_argument(
        "-c",
        "--changelog",
        default="debian/changelog",
        help="Path to debian/changelog; default: %(default)s",
    )
    parser.add_argument(
        "-D",
        "--distribution",
        default="experimental",
        help="Distribution to use, if the patch doesn't already "
        "contain a changelog; default: %(default)s",
    )
    parser.add_argument(
        "--repl", action="store_true", help="Run the python REPL after processing."
    )
    parser.add_argument(
        "--source-version",
        action="store_true",
        help="Don't apply the patch; instead print out the version of the "
        "package that it is supposed to be applied to, or nothing if "
        "the patch does not specify a source version.",
    )
    parser.add_argument(
        "--target-version",
        action="store_true",
        help="Don't apply the patch; instead print out the new version of the "
        "package debdiff-apply(1) would generate, when the patch is applied to the "
        "the given target package, as specified by the other arguments.",
    )
    parser.add_argument(
        "orig_dsc_or_dir",
        nargs="?",
        default=".",
        help="Target to apply the patch to. This can either be an unpacked "
        "source tree, or a .dsc file. In the former case, the directory is "
        "modified in-place; in the latter case, a second .dsc is created. "
        "Default: %(default)s",
    )
    parser.add_argument(
        "patch_file",
        nargs="?",
        default="/dev/stdin",
        help="Patch file to apply, in the format output by debdiff(1)."
        " Default: %(default)s",
    )
    group1 = parser.add_argument_group("Options for .dsc patch targets")
    group1.add_argument(
        "--no-clean",
        action="store_true",
        help="Don't clean temporary directories after a failure, so you can "
        "examine what failed.",
    )
    group1.add_argument(
        "--quilt-refresh",
        action="store_true",
        help="If the building of the new source package fails, try to refresh "
        "patches using quilt(1) then try building it again.",
    )
    group1.add_argument(
        "-d",
        "--directory",
        default=None,
        help="Extract the .dsc into this directory, which won't be cleaned up "
        "after debdiff-apply(1) exits. If not given, then it will be extracted to a "
        "temporary directory.",
    )
    return parser.parse_args(args)


def main(args):
    # Split this function!
    # pylint: disable=too-many-branches,too-many-locals,too-many-statements
    args = parse_args(args)
    if args.verbose:
        logging.getLogger().setLevel(logging.DEBUG)

    with open(args.patch_file, "rb") as fp:
        data = fp.read()
    for enc in TRY_ENCODINGS:
        try:
            patch = unidiff.PatchSet(data.splitlines(keepends=True), encoding=enc)
            break
        except Exception:  # pylint: disable=broad-except
            if enc == TRY_ENCODINGS[-1]:
                raise
            continue

    hex_digest = hashlib.sha256(data).hexdigest()[
        : 20 if args.patch_file == "/dev/stdin" else 8
    ]
    patch_name = f"{os.path.basename(args.patch_file)}:{hex_digest}"
    quiet = args.source_version or args.target_version
    dry_run = args.source_version or args.target_version
    # user can redirect stderr themselves
    stdout = subprocess.DEVNULL if quiet else None

    # change directory before applying patches
    if os.path.isdir(args.orig_dsc_or_dir):
        os.chdir(args.orig_dsc_or_dir)
        debdiff_apply(patch, patch_name, args)
    elif os.path.isfile(args.orig_dsc_or_dir):
        dscfile = args.orig_dsc_or_dir
        parts = os.path.splitext(os.path.basename(dscfile))
        if parts[1] != ".dsc":
            raise ValueError(f"unrecognised patch target: {dscfile}")
        extractdir = args.directory if args.directory else tempfile.mkdtemp()
        if not os.path.isdir(extractdir):
            os.makedirs(extractdir)
        try:
            # dpkg-source doesn't like existing dirs
            builddir = os.path.join(extractdir, parts[0])
            subprocess.check_call(
                ["dpkg-source", "-x", "--skip-patches", dscfile, builddir],
                stdout=stdout,
            )
            origdir = os.getcwd()
            workaround_dpkg_865430(dscfile, origdir, stdout)
            os.chdir(builddir)
            did_patch = debdiff_apply(patch, patch_name, args)
            if dry_run or not did_patch:
                return
            os.chdir(origdir)
            try:
                subprocess.check_call(["dpkg-source", "-b", builddir])
            except subprocess.CalledProcessError:
                if args.quilt_refresh:
                    subprocess.check_call(
                        [
                            "sh",
                            "-c",
                            """
set -ex
export QUILT_PATCHES=debian/patches
while quilt push; do quilt refresh; done
""",
                        ],
                        cwd=builddir,
                    )
                    subprocess.check_call(["dpkg-source", "-b", builddir])
                else:
                    raise
        finally:
            cleandir = builddir if args.directory else extractdir
            if args.no_clean:
                logging.warning("you should clean up temp files in %s", cleandir)
            else:
                shutil.rmtree(cleandir)


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
