#!/usr/bin/python3
# Copyright (C) 2018 Jelmer Vernooij <jelmer@jelmer.uk>
#
# 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 2 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.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA

"""Support for merging new upstream versions."""

from email.utils import parseaddr
import errno
import json
import logging
import os
import re
import socket
import ssl
import tempfile
import traceback
from typing import List, Optional, Callable, Union

from breezy.bzr import LineEndingError
from breezy.git.remote import RemoteGitError
from breezy.workingtree import WorkingTree

from debian.changelog import Version, ChangelogParseError, get_maintainer

from breezy import errors, urlutils

from breezy.branch import Branch
from breezy.commit import (
    PointlessCommit,
)
from breezy.controldir import ControlDir
from breezy.errors import (
    FileExists,
    InvalidNormalization,
    NoSuchFile,
    NotBranchError,
    NoCommits,
    PointlessMerge,
    InvalidHttpResponse,
    NoRoundtrippingSupport,
    UncommittedChanges,
)
from breezy.transport import (
    Transport,
    UnusableRedirect,
    get_transport,
    )
from breezy.plugins.debian.config import UpstreamMetadataSyntaxError
from breezy.plugins.debian.util import (
    InconsistentSourceFormatError,
)
from breezy.plugins.debian.import_dsc import (
    UpstreamAlreadyImported,
    UpstreamBranchAlreadyMerged,
)
from breezy.plugins.debian.changelog import debcommit

from breezy.transform import MalformedTransform

from breezy.plugins.debian.merge_upstream import (
    do_import,
    do_merge,
    get_existing_imported_upstream_revids,
    get_tarballs,
    PreviousVersionTagMissing,
)
from breezy.plugins.debian.repack_tarball import (
    UnsupportedRepackFormat,
)

from breezy.plugins.debian.upstream.pristinetar import (
    PristineTarError,
    get_pristine_tar_source,
)
from breezy.plugins.quilt.quilt import (
    QuiltError,
    QuiltPatches,
)

from breezy.plugins.debian.util import (
    debuild_config,
    guess_build_type,
    get_files_excluded,
    tree_contains_upstream_source,
    BUILD_TYPE_MERGE,
    BUILD_TYPE_NATIVE,
    find_changelog,
    MissingChangelogError,
    control_files_in_root,
    full_branch_url,
)

from breezy.plugins.debian.upstream import (
    TarfileSource,
    MissingUpstreamTarball,
    PackageVersionNotPresent,
)
from breezy.plugins.debian.upstream.uscan import (
    UScanSource,
    UScanError,
    NoWatchFile,
    WatchLineWithoutMatches,
    WatchLineWithoutMatchingHrefs,
)
from breezy.plugins.debian.upstream.branch import (
    UpstreamBranchSource,
    DistCommandFailed,
    run_dist_command,
)

from debmutate.changelog import ChangelogEditor, upstream_merge_changelog_line
from debmutate.reformatting import GeneratedFile
from debmutate.versions import (
    matches_release,
    new_upstream_package_version,
    initial_debian_revision,
    debianize_upstream_version,
    )

from debmutate.vcs import split_vcs_url
from debmutate.watch import WatchSyntaxError

from breezy.tree import Tree

from breezy.plugins.debian.merge_upstream import get_upstream_branch_location


__all__ = [
    "PreviousVersionTagMissing",
    "merge_upstream",
    "InvalidFormatUpstreamVersion",
    "DistCommandFailed",
    "MissingChangelogError",
    "MissingUpstreamTarball",
    "NewUpstreamMissing",
    "UpstreamBranchUnavailable",
    "UnsupportedRepackFormat",
    "UpstreamAlreadyMerged",
    "UpstreamAlreadyImported",
    "UpstreamMergeConflicted",
    "NewUpstreamTarballMissing",
    "QuiltError",
    "NoUpstreamLocationsKnown",
    "UpstreamVersionMissingInUpstreamBranch",
    "UpstreamBranchUnknown",
    "PackageIsNative",
    "ChangelogParseError",
    "UScanError",
    "UpstreamMetadataSyntaxError",
    "QuiltPatchPushFailure",
    "WatchLineWithoutMatches",
    "BigVersionJump",
]


class BigVersionJump(Exception):
    """There was a big version jump."""

    def __init__(self, old_upstream_version, new_upstream_version):
        self.old_upstream_version = old_upstream_version
        self.new_upstream_version = new_upstream_version


class UpstreamMergeConflicted(Exception):
    """The upstream merge resulted in conflicts."""

    def __init__(self, upstream_version, conflicts):
        self.version = upstream_version
        self.conflicts = conflicts


class UpstreamAlreadyMerged(Exception):
    """Upstream release (or later version) has already been merged."""

    def __init__(self, upstream_version):
        self.version = upstream_version


class QuiltPatchPushFailure(Exception):
    def __init__(self, patch_name, actual_error):
        self.patch_name = patch_name
        self.actual_error = actual_error


class UpstreamBranchLocationInvalid(Exception):
    """Upstream branch location is invalid."""

    def __init__(self, url, extra):
        self.url = url
        self.extra = extra


class InvalidFormatUpstreamVersion(Exception):
    """Invalid format upstream version string."""

    def __init__(self, version, source):
        self.version = version
        self.source = source


class UpstreamBranchUnknown(Exception):
    """The location of the upstream branch is unknown."""


class NewUpstreamMissing(Exception):
    """Unable to find upstream version to merge."""


class UpstreamBranchUnavailable(Exception):
    """Snapshot merging was requested by upstream branch is unavailable."""

    def __init__(self, location, error):
        self.location = location
        self.error = error


class UpstreamVersionMissingInUpstreamBranch(Exception):
    """The upstream version is missing in the upstream branch."""

    def __init__(self, upstream_branch, upstream_version):
        self.branch = upstream_branch
        self.version = upstream_version


class PackageIsNative(Exception):
    """Unable to merge upstream version."""

    def __init__(self, package, version):
        self.package = package
        self.version = version


class UpstreamNotBundled(Exception):
    """Packaging branch does not carry upstream sources."""

    def __init__(self, package):
        self.package = package


class NewUpstreamTarballMissing(Exception):
    def __init__(self, package, version, upstream):
        self.package = package
        self.version = version
        self.upstream = upstream


class NoUpstreamLocationsKnown(Exception):
    """No upstream locations (uscan/repository) for the package are known."""

    def __init__(self, package):
        self.package = package


class NewerUpstreamAlreadyImported(Exception):
    """A newer upstream version has already been imported."""

    def __init__(self, old_upstream_version, new_upstream_version):
        self.old_upstream_version = old_upstream_version
        self.new_upstream_version = new_upstream_version


class ChangelogGeneratedFile(Exception):
    """The changelog file is generated."""

    def __init__(self, path, template_path, template_type):
        self.path = path
        self.template_path = template_path
        self.template_type = template_type


RELEASE_BRANCH_NAME = "new-upstream-release"
SNAPSHOT_BRANCH_NAME = "new-upstream-snapshot"
DEFAULT_DISTRIBUTION = "unstable"


def is_big_version_jump(old_upstream_version, new_upstream_version):
    try:
        old_major_version = int(str(old_upstream_version).split('.')[0])
    except ValueError:
        return False
    try:
        new_major_version = int(str(new_upstream_version).split('.')[0])
    except ValueError:
        return False
    if old_major_version > 0 and new_major_version > 5*old_major_version:
        return True
    return False


def refresh_quilt_patches(
    local_tree: Tree,
    old_version: Version,
    new_version: Version,
    committer: Optional[str] = None,
    subpath: str = "",
) -> None:
    # TODO(jelmer):
    # Find patch base branch.
    #   If it exists, rebase it onto the new upstream.
    #   And then run 'gbp pqm export' or similar
    # If not:
    #   Refresh patches against the new upstream revision
    patches = QuiltPatches(local_tree, os.path.join(subpath, "debian/patches"))
    patches.upgrade()
    for name in patches.unapplied():
        try:
            patches.push(name, refresh=True)
        except QuiltError as e:
            lines = e.stdout.splitlines()
            m = re.match(
                "Patch debian/patches/(.*) can be reverse-applied",
                lines[-1])
            if m and getattr(patches, "delete", None):
                assert m.group(1) == name
                patches.delete(name, remove=True)
                with ChangelogEditor(
                        local_tree.abspath(
                            os.path.join(subpath, 'debian/changelog'))
                        ) as cl:
                    cl.add_entry(["Drop patch %s, present upstream." % name])
                debcommit(
                    local_tree,
                    committer=committer,
                    subpath=subpath,
                    paths=[
                        "debian/patches/series",
                        "debian/patches/" + name,
                        "debian/changelog",
                    ],
                )
            else:
                raise QuiltPatchPushFailure(name, e)
    patches.pop_all()
    try:
        local_tree.commit(
            "Refresh patches.", committer=committer, allow_pointless=False
        )
    except PointlessCommit:
        pass


class ImportUpstreamResult(object):
    """Object representing the result of an import_upstream operation."""

    __slots__ = [
        "old_upstream_version",
        "new_upstream_version",
        "upstream_branch",
        "upstream_branch_browse",
        "upstream_revisions",
        "imported_revisions",
        "include_upstream_history",
    ]

    def __init__(
        self,
        include_upstream_history,
        old_upstream_version,
        new_upstream_version,
        upstream_branch,
        upstream_branch_browse,
        upstream_revisions,
        imported_revisions,
    ):
        self.old_upstream_version = old_upstream_version
        self.new_upstream_version = new_upstream_version
        self.upstream_branch = upstream_branch
        self.upstream_branch_browse = upstream_branch_browse
        self.upstream_revisions = upstream_revisions
        self.imported_revisions = imported_revisions
        self.include_upstream_history = include_upstream_history


def detect_include_upstream_history(
    tree, upstream_branch_source, package, old_upstream_version
):
    # Simple heuristic: Find the old upstream version and see if it's present
    # in the history of the packaging branch
    try:
        revision = upstream_branch_source.version_as_revision(
            package, old_upstream_version
        )
    except PackageVersionNotPresent:
        logging.warning(
            "Old upstream version %r is not present in upstream "
            "branch %r. Unable to determine whether upstream history "
            "is normally included. Assuming no.",
            old_upstream_version,
            upstream_branch_source,
        )
        return False

    graph = tree.branch.repository.get_graph()
    ret = graph.is_ancestor(revision, tree.last_revision())
    if ret:
        logging.info(
            "Including upstream history, since previous upstream version "
            "(%s) is present in packaging branch history.",
            old_upstream_version,
        )
    else:
        logging.info(
            "Not including upstream history, since previous upstream version "
            "(%s) is not present in packaging branch history.",
            old_upstream_version,
        )

    return ret


class BranchOpenError(Exception):
    """Generic handler for branch open failures."""


def _convert_exception(url: str, e: Exception) -> Optional[BranchOpenError]:
    if isinstance(e, socket.error):
        return BranchOpenError(url, "Socket error: %s" % e)
    if isinstance(e, errors.NotBranchError):
        return BranchOpenError(url, "Branch does not exist: %s" % e)
    if isinstance(e, errors.UnsupportedProtocol):
        return BranchOpenError(url, str(e))
    if isinstance(e, errors.ConnectionError):
        return BranchOpenError(url, str(e))
    if isinstance(e, errors.PermissionDenied):
        return BranchOpenError(url, str(e))
    if isinstance(e, errors.InvalidHttpResponse):
        if "Unexpected HTTP status 429" in str(e):
            raise BranchOpenError(url, str(e))
        return BranchOpenError(url, str(e))
    if isinstance(e, errors.TransportError):
        return BranchOpenError(url, str(e))
    if isinstance(e, UnusableRedirect):
        return BranchOpenError(url, str(e))
    if isinstance(e, errors.UnsupportedFormatError):
        return BranchOpenError(url, str(e))
    if isinstance(e, errors.UnknownFormatError):
        return BranchOpenError(url, str(e))
    if isinstance(e, RemoteGitError):
        return BranchOpenError(url, str(e))
    if isinstance(e, LineEndingError):
        return BranchOpenError(url, str(e))
    return None


def open_branch(
    url: str,
    possible_transports: Optional[List[Transport]] = None,
    name: str = None,
) -> Branch:
    """Open a branch by URL."""
    url, params = urlutils.split_segment_parameters(url)
    if name is None:
        try:
            name = urlutils.unquote(params["branch"])
        except KeyError:
            name = None
    try:
        transport = get_transport(url, possible_transports=possible_transports)
        dir = ControlDir.open_from_transport(transport)
        return dir.open_branch(name=name)
    except Exception as e:
        converted = _convert_exception(url, e)
        if converted is not None:
            raise converted
        raise e


def find_new_upstream(  # noqa: C901
    tree,
    subpath,
    config,
    package,
    location=None,
    old_upstream_version=None,
    new_upstream_version=None,
    trust_package=False,
    snapshot=False,
    allow_ignore_upstream_branch=False,
    top_level=False,
    create_dist=None,
    include_upstream_history: Optional[bool] = None,
    force_big_version_jump: bool = False,
    require_uscan: bool = False,
):

    # TODO(jelmer): Find the lastest upstream present in the upstream branch
    # rather than what's in the changelog.

    upstream_branch_location, upstream_branch_browse = (
            get_upstream_branch_location(
                tree, subpath, config, trust_package=trust_package))

    if upstream_branch_location:
        (upstream_branch_url, upstream_branch_name,
         upstream_subpath) = split_vcs_url(upstream_branch_location)
        if upstream_branch_name:
            upstream_branch_url = urlutils.join_segment_parameters(
                upstream_branch_url, {'branch': upstream_branch_name})
        try:
            upstream_branch = open_branch(upstream_branch_url)
        except BranchOpenError as e:
            if not snapshot and allow_ignore_upstream_branch:
                logging.warning(
                    "Upstream branch %s inaccessible; ignoring. %s",
                    upstream_branch_location,
                    e,
                )
            else:
                raise UpstreamBranchUnavailable(upstream_branch_location, e)
            upstream_branch = None
            upstream_branch_browse = None
        except urlutils.InvalidURL as e:
            if not snapshot and allow_ignore_upstream_branch:
                logging.warning(
                    "Upstream branch location %s invalid; ignoring. %s",
                    e.path, e)
            else:
                raise UpstreamBranchLocationInvalid(e.path, e.extra)
            upstream_branch = None
            upstream_branch_browse = None
    else:
        upstream_branch = None
        upstream_subpath = None  # noqa: F841

    if upstream_branch is not None:
        if upstream_subpath:
            raise Exception(
                'merging from upstream with subpath not yet supported')
        try:
            upstream_branch_source = UpstreamBranchSource.from_branch(
                upstream_branch,
                config=config,
                local_dir=tree.controldir,
                create_dist=create_dist,
                version_kind=("snapshot" if snapshot else "release")
            )
        except InvalidHttpResponse as e:
            raise UpstreamBranchUnavailable(upstream_branch_location, str(e))
        except ssl.SSLError as e:
            raise UpstreamBranchUnavailable(upstream_branch_location, str(e))
    else:
        upstream_branch_source = None

    if location is not None:
        try:
            branch = open_branch(location)
        except BranchOpenError:
            primary_upstream_source = TarfileSource(
                location, new_upstream_version)
        else:
            primary_upstream_source = UpstreamBranchSource.from_branch(
                branch,
                config=config,
                local_dir=tree.controldir,
                create_dist=create_dist,
                version_kind=("snapshot" if snapshot else "release")
            )
    else:
        if snapshot:
            if upstream_branch_source is None:
                raise UpstreamBranchUnknown()
            primary_upstream_source = upstream_branch_source
        else:
            try:
                primary_upstream_source = UScanSource.from_tree(
                    tree, subpath, top_level, auto_fix=True
                )
            except NoWatchFile:
                # TODO(jelmer): Call out to lintian_brush.watch to generate a
                # watch file.
                if upstream_branch_source is None:
                    raise NoUpstreamLocationsKnown(package)
                if require_uscan:
                    raise
                primary_upstream_source = upstream_branch_source

    if new_upstream_version is None and primary_upstream_source is not None:
        unmangled_new_upstream_version, new_upstream_version = (
                primary_upstream_source.get_latest_version(
                    package, old_upstream_version))
    else:
        new_upstream_version = debianize_upstream_version(
            new_upstream_version)

    if new_upstream_version is None:
        raise NewUpstreamMissing()

    if not new_upstream_version[0].isdigit():
        # dpkg forbids this, let's just refuse it early
        raise InvalidFormatUpstreamVersion(
            new_upstream_version, primary_upstream_source
        )

    try:
        new_upstream_version = Version(new_upstream_version)
    except ValueError:
        raise InvalidFormatUpstreamVersion(
            new_upstream_version, primary_upstream_source
        )

    if old_upstream_version:
        if old_upstream_version == new_upstream_version:
            raise UpstreamAlreadyImported(new_upstream_version)
        if old_upstream_version > new_upstream_version:
            if not snapshot and matches_release(
                str(old_upstream_version), str(new_upstream_version)
            ):
                raise UpstreamAlreadyImported(new_upstream_version)
            raise NewerUpstreamAlreadyImported(
                old_upstream_version, new_upstream_version
            )
        if (is_big_version_jump(old_upstream_version, new_upstream_version) and
                not force_big_version_jump):
            raise BigVersionJump(old_upstream_version, new_upstream_version)

    # TODO(jelmer): Check if new_upstream_version is already imported

    logging.info("Using version string %s.", new_upstream_version)

    if include_upstream_history is None and upstream_branch_source is not None:
        include_upstream_history = detect_include_upstream_history(
            tree, upstream_branch_source, package, old_upstream_version
        )

    if include_upstream_history is False:
        upstream_branch_source = None

    # Look up the revision id from the version string
    if upstream_branch_source is not None:
        try:
            upstream_revisions = upstream_branch_source.version_as_revisions(
                package, str(new_upstream_version)
            )
        except PackageVersionNotPresent:
            if upstream_branch_source is primary_upstream_source:
                # The branch is our primary upstream source, so if it can't
                # find the version then there's nothing we can do.
                raise UpstreamVersionMissingInUpstreamBranch(
                    upstream_branch_source.upstream_branch,
                    str(new_upstream_version)
                )
            elif not allow_ignore_upstream_branch:
                raise UpstreamVersionMissingInUpstreamBranch(
                    upstream_branch_source.upstream_branch,
                    str(new_upstream_version)
                )
            else:
                logging.warning(
                    "Upstream version %s is not in upstream branch %s. "
                    "Not merging from upstream branch. ",
                    new_upstream_version,
                    upstream_branch_source.upstream_branch,
                )
                upstream_revisions = None
                upstream_branch_source = None
    else:
        upstream_revisions = None

    try:
        files_excluded = get_files_excluded(tree, subpath, top_level)
    except NoSuchFile:
        files_excluded = None

    return (
        primary_upstream_source,
        new_upstream_version,
        upstream_revisions,
        upstream_branch_source,
        upstream_branch,
        upstream_branch_browse,
        files_excluded,
        include_upstream_history,
    )


def import_upstream(
    tree: Tree,
    snapshot: bool = False,
    location: Optional[str] = None,
    new_upstream_version: Optional[Union[Version, str]] = None,
    force: bool = False,
    distribution_name: str = DEFAULT_DISTRIBUTION,
    allow_ignore_upstream_branch: bool = True,
    trust_package: bool = False,
    committer: Optional[str] = None,
    subpath: str = "",
    include_upstream_history: Optional[bool] = None,
    create_dist:
        Optional[Callable[[Tree, str, Version, str], Optional[str]]] = None,
    force_big_version_jump: bool = False,
) -> ImportUpstreamResult:
    """Import a new upstream version into a tree.

    Raises:
      InvalidFormatUpstreamVersion
      PreviousVersionTagMissing
      UnsupportedRepackFormat
      DistCommandFailed
      MissingChangelogError
      MissingUpstreamTarball
      NewUpstreamMissing
      UpstreamBranchUnavailable
      UpstreamAlreadyImported
      QuiltError
      UpstreamVersionMissingInUpstreamBranch
      UpstreamBranchUnknown
      PackageIsNative
      InconsistentSourceFormatError
      ChangelogParseError
      UScanError
      UpstreamMetadataSyntaxError
      UpstreamNotBundled
      NewUpstreamTarballMissing
      NoUpstreamLocationsKnown
      NewerUpstreamAlreadyImported
    Returns:
      ImportUpstreamResult object
    """
    if subpath is None:
        subpath = ""
    config = debuild_config(tree, subpath)
    (changelog, top_level) = find_changelog(
        tree, subpath, merge=False, max_blocks=2)
    old_upstream_version = changelog.version.upstream_version
    package = changelog.package
    contains_upstream_source = tree_contains_upstream_source(tree, subpath)
    build_type = config.build_type
    if build_type is None:
        build_type = guess_build_type(
            tree,
            changelog.version,
            subpath,
            contains_upstream_source=contains_upstream_source,
        )
    if build_type == BUILD_TYPE_MERGE:
        raise UpstreamNotBundled(changelog.package)
    if build_type == BUILD_TYPE_NATIVE:
        raise PackageIsNative(changelog.package, changelog.version)

    (
        primary_upstream_source,
        new_upstream_version,
        upstream_revisions,
        upstream_branch_source,
        upstream_branch,
        upstream_branch_browse,
        files_excluded,
        include_upstream_history,
    ) = find_new_upstream(
        tree,
        subpath,
        config,
        package,
        location=location,
        old_upstream_version=old_upstream_version,
        new_upstream_version=new_upstream_version,
        trust_package=trust_package,
        snapshot=snapshot,
        allow_ignore_upstream_branch=allow_ignore_upstream_branch,
        top_level=top_level,
        include_upstream_history=include_upstream_history,
        create_dist=create_dist,
        force_big_version_jump=force_big_version_jump,
    )

    with tempfile.TemporaryDirectory() as target_dir:
        initial_path = os.path.join(target_dir, "initial")
        os.mkdir(initial_path)
        try:
            locations = primary_upstream_source.fetch_tarballs(
                package, str(new_upstream_version), target_dir,
                components=[None]
            )
        except (PackageVersionNotPresent, WatchLineWithoutMatchingHrefs):
            if upstream_revisions is not None:
                locations = upstream_branch_source.fetch_tarballs(
                    package,
                    str(new_upstream_version),
                    initial_path,
                    components=[None],
                    revisions=upstream_revisions,
                )
            else:
                raise
        orig_path = os.path.join(target_dir, "orig")
        try:
            tarball_filenames = get_tarballs(
                orig_path, tree, package, new_upstream_version, locations
            )
        except FileExists as e:
            raise AssertionError(
                "The target file %s already exists, and is either "
                "different to the new upstream tarball, or they "
                "are of different formats. Either delete the target "
                "file, or use it as the argument to import." % e.path
            )
        imported_revisions = do_import(
            tree,
            subpath,
            tarball_filenames,
            package,
            str(new_upstream_version),
            str(old_upstream_version),
            upstream_branch_source.upstream_branch
            if upstream_branch_source else None,
            upstream_revisions,
            merge_type=None,
            force=force,
            committer=committer,
            files_excluded=files_excluded,
        )

    return ImportUpstreamResult(
        include_upstream_history=include_upstream_history,
        old_upstream_version=old_upstream_version,
        new_upstream_version=new_upstream_version,
        upstream_branch=upstream_branch,
        upstream_branch_browse=upstream_branch_browse,
        upstream_revisions=upstream_revisions,
        imported_revisions=imported_revisions,
    )


class MergeUpstreamResult(object):
    """Object representing the result of a merge_upstream operation."""

    __slots__ = [
        "old_upstream_version",
        "new_upstream_version",
        "upstream_branch",
        "upstream_branch_browse",
        "upstream_revisions",
        "old_revision",
        "new_revision",
        "imported_revisions",
        "include_upstream_history",
    ]

    def __init__(
        self,
        include_upstream_history,
        old_upstream_version,
        new_upstream_version,
        upstream_branch,
        upstream_branch_browse,
        upstream_revisions,
        old_revision,
        new_revision,
        imported_revisions,
    ):
        self.include_upstream_history = include_upstream_history
        self.old_upstream_version = old_upstream_version
        self.new_upstream_version = new_upstream_version
        self.upstream_branch = upstream_branch
        self.upstream_branch_browse = upstream_branch_browse
        self.upstream_revisions = upstream_revisions
        self.old_revision = old_revision
        self.new_revision = new_revision
        self.imported_revisions = imported_revisions

    def __tuple__(self):
        # Backwards compatibility
        return (self.old_upstream_version, self.new_upstream_version)


def merge_upstream(  # noqa: C901
    tree: Tree,
    snapshot: bool = False,
    location: Optional[str] = None,
    new_upstream_version: Optional[str] = None,
    force: bool = False,
    distribution_name: str = DEFAULT_DISTRIBUTION,
    allow_ignore_upstream_branch: bool = True,
    trust_package: bool = False,
    committer: Optional[str] = None,
    update_changelog: bool = True,
    subpath: str = "",
    include_upstream_history: Optional[bool] = None,
    create_dist: Optional[
        Callable[[Tree, str, Version, str], Optional[str]]] = None,
    force_big_version_jump: bool = False,
    debian_revision: Optional[str] = None,
    require_uscan: bool = False
) -> MergeUpstreamResult:
    """Merge a new upstream version into a tree.

    Raises:
      InvalidFormatUpstreamVersion
      PreviousVersionTagMissing
      UnsupportedRepackFormat
      DistCommandFailed
      MissingChangelogError
      MissingUpstreamTarball
      NewUpstreamMissing
      UpstreamBranchUnavailable
      UpstreamAlreadyMerged
      UpstreamAlreadyImported
      UpstreamMergeConflicted
      QuiltError
      UpstreamVersionMissingInUpstreamBranch
      UpstreamBranchUnknown
      PackageIsNative
      InconsistentSourceFormatError
      ChangelogParseError
      UScanError
      NoUpstreamLocationsKnown
      UpstreamMetadataSyntaxError
      NewerUpstreamAlreadyImported
      WatchSyntaxError
    Returns:
      MergeUpstreamResult object
    """
    if subpath is None:
        subpath = ""
    config = debuild_config(tree, subpath)
    (changelog, top_level) = find_changelog(
        tree, subpath, merge=False, max_blocks=2)
    old_upstream_version = changelog.version.upstream_version
    old_revision = tree.last_revision()
    package = changelog.package
    contains_upstream_source = tree_contains_upstream_source(tree, subpath)
    build_type = config.build_type
    if build_type is None:
        build_type = guess_build_type(
            tree,
            changelog.version,
            subpath,
            contains_upstream_source=contains_upstream_source,
        )
    need_upstream_tarball = build_type != BUILD_TYPE_MERGE
    if build_type == BUILD_TYPE_NATIVE:
        raise PackageIsNative(changelog.package, changelog.version)

    (
        primary_upstream_source,
        new_upstream_version,
        upstream_revisions,
        upstream_branch_source,
        upstream_branch,
        upstream_branch_browse,
        files_excluded,
        include_upstream_history,
    ) = find_new_upstream(
        tree,
        subpath,
        config,
        package,
        location=location,
        old_upstream_version=old_upstream_version,
        new_upstream_version=new_upstream_version,
        trust_package=trust_package,
        snapshot=snapshot,
        allow_ignore_upstream_branch=allow_ignore_upstream_branch,
        top_level=top_level,
        include_upstream_history=include_upstream_history,
        create_dist=create_dist,
        force_big_version_jump=force_big_version_jump,
        require_uscan=require_uscan,
    )

    if need_upstream_tarball:
        with tempfile.TemporaryDirectory() as target_dir:
            initial_path = os.path.join(target_dir, "initial")
            os.mkdir(initial_path)

            try:
                locations = primary_upstream_source.fetch_tarballs(
                    package, str(new_upstream_version), initial_path,
                    components=[None])
            except PackageVersionNotPresent as e:
                if upstream_revisions is not None:
                    locations = upstream_branch_source.fetch_tarballs(
                        package,
                        str(new_upstream_version),
                        initial_path,
                        components=[None],
                        revisions=upstream_revisions,
                    )
                else:
                    raise NewUpstreamTarballMissing(
                        e.package, e.version, e.upstream)

            orig_path = os.path.join(target_dir, "orig")
            os.mkdir(orig_path)
            try:
                tarball_filenames = get_tarballs(
                    orig_path, tree, package, new_upstream_version, locations
                )
            except FileExists as e:
                raise AssertionError(
                    "The target file %s already exists, and is either "
                    "different to the new upstream tarball, or they "
                    "are of different formats. Either delete the target "
                    "file, or use it as the argument to import." % e.path
                )
            try:
                conflicts, imported_revids = do_merge(
                    tree,
                    subpath,
                    tarball_filenames,
                    package,
                    str(new_upstream_version),
                    str(old_upstream_version),
                    upstream_branch_source.upstream_branch
                    if upstream_branch_source
                    else None,
                    upstream_revisions,
                    merge_type=None,
                    force=force,
                    committer=committer,
                    files_excluded=files_excluded,
                )
            except UpstreamBranchAlreadyMerged:
                # TODO(jelmer): Perhaps reconcile these two exceptions?
                raise UpstreamAlreadyMerged(new_upstream_version)
            except UpstreamAlreadyImported:
                pristine_tar_source = get_pristine_tar_source(
                    tree, tree.branch)
                imported_revids = get_existing_imported_upstream_revids(
                    pristine_tar_source, package, new_upstream_version)
                for entry in imported_revids:
                    if entry[0] is None:
                        upstream_revid = entry[2]
                else:
                    upstream_revid = None
                try:
                    try:
                        conflicts = tree.merge_from_branch(
                            pristine_tar_source.branch, to_revision=upstream_revid
                        )
                    except NoCommits:
                        # Maybe it's somewhere in our history, but
                        # there isn't a viable upstream branch.
                        conflicts = tree.merge_from_branch(
                            tree.branch, to_revision=upstream_revid
                        )
                except PointlessMerge:
                    raise UpstreamAlreadyMerged(new_upstream_version)
    else:
        conflicts = 0
        imported_revids = []

    # Re-read changelog, since it may have been changed by the merge
    # from upstream.
    try:
        (changelog, top_level) = find_changelog(
            tree, subpath, False, max_blocks=2)
    except (ChangelogParseError, MissingChangelogError):
        # If there was a conflict that affected debian/changelog, then that might be
        # to blame.
        if conflicts:
            raise UpstreamMergeConflicted(old_upstream_version, conflicts)
        raise
    if top_level:
        debian_path = subpath
    else:
        debian_path = os.path.join(subpath, "debian")

    if Version(old_upstream_version) >= Version(new_upstream_version):
        if conflicts:
            raise UpstreamMergeConflicted(old_upstream_version, conflicts)
        raise UpstreamAlreadyMerged(new_upstream_version)

    try:
        with ChangelogEditor(
                tree.abspath(os.path.join(debian_path, "changelog"))) as cl:
            if debian_revision is None:
                debian_revision = initial_debian_revision(distribution_name)
            new_version = str(new_upstream_package_version(
                new_upstream_version, debian_revision, cl[0].version.epoch))
            if not update_changelog:
                # We need to run "gbp dch" here, since the next "gbp dch" runs
                # won't pick up the pending changes, as we're about to change
                # debian/changelog.
                from debmutate.changelog import gbp_dch
                gbp_dch(tree.basedir)
            cl.auto_version(new_version)

            cl.add_entry([upstream_merge_changelog_line(new_upstream_version)])
    except GeneratedFile as e:
        raise ChangelogGeneratedFile(e.path, e.template_path, e.template_type)

    if not need_upstream_tarball:
        logging.info("The changelog has been updated for the new version.")
    else:
        if conflicts:
            raise UpstreamMergeConflicted(new_upstream_version, conflicts)

    debcommit(tree, subpath=subpath, committer=committer)

    return MergeUpstreamResult(
        include_upstream_history=include_upstream_history,
        old_upstream_version=old_upstream_version,
        new_upstream_version=new_upstream_version,
        old_revision=old_revision,
        new_revision=tree.last_revision(),
        upstream_branch=upstream_branch,
        upstream_branch_browse=upstream_branch_browse,
        upstream_revisions=upstream_revisions,
        imported_revisions=imported_revids,
    )


def override_dh_autoreconf_add_arguments(basedir: str, args):
    from debmutate._rules import update_rules

    # TODO(jelmer): Make sure dh-autoreconf is installed,
    # or debhelper version is >= 10

    def update_makefile(mf):
        for rule in mf.iter_rules(b"override_dh_autoreconf"):
            command = rule.commands()[0].split(b" ")
            if command[0] != b"dh_autoreconf":
                return
            rule.lines = [rule.lines[0]]
            command += args
            break
        else:
            rule = mf.add_rule(b"override_dh_autoreconf")
            command = [b"dh_autoreconf"] + args
        rule.append_command(b" ".join(command))

    return update_rules(
        makefile_cb=update_makefile,
        path=os.path.join(basedir, "debian", "rules")
    )


def update_packaging(
    tree: Tree, old_tree: Tree, subpath: str = "",
    committer: Optional[str] = None) -> List[str]:
    """Update packaging to take in changes between upstream trees.

    Args:
      tree: Current tree
      old_tree: Old tree
      committer: Optional committer to use for changes
    """
    if committer is None:
        maintainer = get_maintainer()
    else:
        maintainer = parseaddr(committer)
    notes = []
    tree_delta = tree.changes_from(old_tree, specific_files=[subpath])
    for delta in tree_delta.added:
        path = delta.path[1]
        if path is None:
            continue
        if not path.startswith(subpath):
            continue
        path = path[len(subpath) :]
        if path == "autogen.sh":
            if override_dh_autoreconf_add_arguments(tree.basedir, [b"./autogen.sh"]):
                logging.info(
                    "Modifying debian/rules: " "Invoke autogen.sh from dh_autoreconf."
                )
                with ChangelogEditor(
                        tree.abspath(os.path.join(subpath, 'debian/changelog'))) as cl:
                    cl.add_entry(["Invoke autogen.sh from dh_autoreconf."], maintainer=maintainer)
                debcommit(
                    tree,
                    committer=committer,
                    subpath=subpath,
                    paths=["debian/changelog", "debian/rules"],
                )
        elif path.startswith("LICENSE") or path.startswith("COPYING"):
            notes.append("License file %s has changed." % os.path.join(subpath, path))
    return notes


def report_fatal(code, description, upstream_version=None, conflicts=None):
    if os.environ.get('SVP_API') == '1':
        context = {}
        if upstream_version is not None:
            context['upstream_version'] = str(upstream_version)
        if conflicts is not None:
            context['conflicts'] = conflicts
        with open(os.environ['SVP_RESULT'], 'w') as f:
            json.dump({
                'result_code': code,
                'description': description,
                'context': context}, f)
    logging.fatal('%s', description)


async def main():
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--trust-package",
        action="store_true",
        default=False,
        help=argparse.SUPPRESS,
    )
    parser.add_argument(
        "--import-only",
        action="store_true",
        help="Only import a new version, do not merge.",
    )
    parser.add_argument(
        "--update-packaging",
        action="store_true",
        default=False,
        help="Attempt to update packaging to upstream changes.",
    )
    parser.add_argument(
        "--snapshot",
        help="Merge a new upstream snapshot rather than a release",
        action="store_true",
    )
    parser.add_argument(
        "--refresh-patches",
        action="store_true",
        help="Refresh quilt patches after upstream merge.",
    )
    parser.add_argument(
        "--dist-command",
        type=str,
        help="Command to run to create tarball from source tree.",
        default=os.environ.get('DIST'),
    )
    parser.add_argument(
        "--no-include-upstream-history",
        action="store_false",
        default=None,
        dest="include_upstream_history",
        help="do not include upstream branch history",
    )
    parser.add_argument(
        "--include-upstream-history",
        action="store_true",
        dest="include_upstream_history",
        help="force inclusion of upstream history",
        default=None,
    )
    parser.add_argument(
        "--force-big-version-jump",
        action="store_true",
        help="force through big version jumps")
    parser.add_argument(
        "--debian-revision",
        type=str,
        help="Debian revision to use (e.g. '1' or '0ubuntu1')",
        default=None)
    parser.add_argument(
        "--require-uscan",
        action="store_true",
        help=("Require that uscan provides a tarball"
              "(if --snapshot is not specified)"))
    parser.add_argument(
        "--verbose", action="store_true")
    args = parser.parse_args()

    if args.verbose:
        loglevel = logging.DEBUG
    else:
        loglevel = logging.INFO
    logging.basicConfig(level=loglevel, format='%(message)s')

    local_tree, subpath = WorkingTree.open_containing('.')

    with local_tree.lock_write():
        committer = os.environ.get('COMMITTER')

        if control_files_in_root(local_tree, subpath):
            report_fatal(
                "control-files-in-root",
                "control files live in root rather than debian/ "
                "(LarstIQ mode)",
            )
            return 1

        if args.dist_command:

            def create_dist(tree, package, version, target_dir):
                return run_dist_command(
                    tree, package, version, target_dir, args.dist_command)
        else:
            create_dist = None

        update_changelog = True
        if os.environ.get('DEB_UPDATE_CHANGELOG') == 'leave':
            update_changelog = False
        elif os.environ.get('DEB_UPDATE_CHANGELOG') == 'update':
            update_changelog = True

        try:
            if not args.import_only:
                try:
                    result = merge_upstream(
                        tree=local_tree,
                        snapshot=args.snapshot,
                        trust_package=args.trust_package,
                        update_changelog=update_changelog,
                        subpath=subpath,
                        committer=committer,
                        include_upstream_history=args.include_upstream_history,
                        create_dist=create_dist,
                        force_big_version_jump=args.force_big_version_jump,
                        debian_revision=args.debian_revision,
                        require_uscan=args.require_uscan,
                    )
                except MalformedTransform:
                    traceback.print_exc()
                    error_description = (
                        "Malformed tree transform during new upstream merge"
                    )
                    error_code = "malformed-transform"
                    report_fatal(error_code, error_description)
                    return 1
                except UncommittedChanges as e:
                    report_fatal('uncommitted-changes', str(e))
                    return 1
            else:
                result = import_upstream(
                    tree=local_tree,
                    snapshot=args.snapshot,
                    trust_package=args.trust_package,
                    subpath=subpath,
                    committer=committer,
                    include_upstream_history=args.include_upstream_history,
                    create_dist=create_dist,
                    force_big_version_jump=args.force_big_version_jump,
                )
        except MemoryError as e:
            report_fatal("memory-error", str(e))
            return 1
        except UpstreamAlreadyImported as e:
            if args.snapshot:
                report_fatal(
                    "nothing-to-do",
                    "Last upstream version %s already imported." % e.version,
                    upstream_version=e.version
                )
            else:
                report_fatal(
                    "nothing-to-do",
                    "Last upstream version %s already imported. "
                    "Import a snapshot by specifying --snapshot." % e.version,
                    upstream_version=e.version
                )
            return 1
        except UpstreamBranchLocationInvalid as e:
            report_fatal(
                "upstream-branch-invalid",
                "The upstream branch location (%s) is invalid: %s" % (
                    e.url, e.extra))
            return 1
        except ChangelogGeneratedFile as e:
            report_fatal(
                "changelog-generated-file",
                "changelog file can't be updated because it is generated. "
                "(template type: %s, path: %s)" % (
                    e.template_type, e.template_path))
            return 1
        except UnsupportedRepackFormat as e:
            error_description = (
                "Unable to repack file %s to supported tarball format."
                % (os.path.basename(e.location))
            )
            report_fatal("unsupported-repack-format", error_description)
            return 1
        except NewUpstreamMissing as e:
            report_fatal(
                "new-upstream-missing", "Unable to find new upstream source.", e
            )
            return 1
        except UpstreamAlreadyMerged as e:
            report_fatal(
                "nothing-to-do",
                "Last upstream version %s already merged." % e.version,
                upstream_version=e.version)
            return 1
        except NoWatchFile:
            report_fatal(
                "no-watch-file",
                "No watch file is present, but --require-uscan was specified")
            return 1
        except PreviousVersionTagMissing as e:
            report_fatal(
                "previous-upstream-missing",
                "Previous upstream version %s missing (tag: %s)."
                % (e.version, e.tag_name),
            )
            return 1
        except InvalidFormatUpstreamVersion as e:
            report_fatal(
                "invalid-upstream-version-format",
                "%r reported invalid format version string %s." % (
                    e.source, e.version),
            )
            return 1
        except PristineTarError as e:
            report_fatal("pristine-tar-error", "Pristine tar error: %s" % e)
            return 1
        except UpstreamBranchUnavailable as e:
            error_description = "The upstream branch at %s was unavailable: %s" % (
                e.location,
                e.error,
            )
            error_code = "upstream-branch-unavailable"
            if "Fossil branches are not yet supported" in str(e.error):
                error_code = "upstream-unsupported-vcs-fossil"
            if "Mercurial branches are not yet supported." in str(e.error):
                error_code = "upstream-unsupported-vcs-hg"
            if "Subversion branches are not yet supported." in str(e.error):
                error_code = "upstream-unsupported-vcs-svn"
            if "Darcs branches are not yet supported" in str(e.error):
                error_code = "upstream-unsupported-vcs-darcs"
            if "Unsupported protocol for url" in str(e.error):
                if "svn://" in str(e.error):
                    error_code = "upstream-unsupported-vcs-svn"
                elif "cvs+pserver://" in str(e.error):
                    error_code = "upstream-unsupported-vcs-cvs"
                else:
                    error_code = "upstream-unsupported-vcs"
            report_fatal(error_code, error_description)
            return 1
        except UpstreamBranchUnknown as e:
            report_fatal(
                "upstream-branch-unknown",
                "Upstream branch location unknown. "
                "Set 'Repository' field in debian/upstream/metadata?",
            )
            return 1
        except UpstreamMergeConflicted as e:
            if isinstance(e.conflicts, int):
                conflicts = e.conflicts
            else:
                conflicts = [[c.path, c.typestring] for c in e.conflicts]
            report_fatal(
                "upstream-merged-conflicts",
                "Merging upstream version %s resulted in conflicts." % e.version,
                upstream_version=e.version, conflicts=conflicts)
            return 1
        except PackageIsNative as e:
            report_fatal(
                "native-package",
                "Package %s is native; unable to merge new upstream." % (e.package,),
            )
            return 1
        except ChangelogParseError as e:
            error_description = str(e)
            error_code = "unparseable-changelog"
            report_fatal(error_code, error_description)
            return 1
        except UpstreamVersionMissingInUpstreamBranch as e:
            error_description = "Upstream version %s not in upstream branch %r" % (
                e.version,
                e.branch,
            )
            error_code = "upstream-version-missing-in-upstream-branch"
            report_fatal(error_code, error_description)
            return 1
        except InconsistentSourceFormatError as e:
            report_fatal(
                "inconsistent-source-format",
                "Inconsistencies in type of package: %s" % e,
            )
            return 1
        except WatchLineWithoutMatches as e:
            report_fatal(
                "uscan-watch-line-without-matches",
                "UScan did not find matches for line: %s" % e.line.strip(),
            )
            return 1
        except NoRoundtrippingSupport:
            error_description = (
                "Unable to import upstream repository into "
                "packaging repository."
            )
            error_code = "roundtripping-error"
            report_fatal(error_code, error_description)
            return 1
        except UScanError as e:
            error_description = str(e)
            if e.errors == "OpenPGP signature did not verify.":
                error_code = "upstream-pgp-signature-verification-failed"
            else:
                error_code = "uscan-error"
            report_fatal(error_code, error_description)
            return 1
        except UpstreamMetadataSyntaxError as e:
            report_fatal(
                "upstream-metadata-syntax-error",
                "Unable to parse %s: %s" % (e.path, e.error),
            )
            return 1
        except InvalidNormalization as e:
            error_description = str(e)
            error_code = "invalid-path-normalization"
            report_fatal(error_code, error_description)
            return 1
        except MissingChangelogError as e:
            report_fatal("missing-changelog", "Missing changelog %s" % e)
            return 1
        except DistCommandFailed as e:
            report_fatal(e.kind or "dist-command-failed", e.error)
            return 1
        except MissingUpstreamTarball as e:
            report_fatal(
                "missing-upstream-tarball", "Missing upstream tarball: %s" % e
            )
            return 1
        except NewUpstreamTarballMissing as e:
            report_fatal(
                "new-upstream-tarball-missing",
                "New upstream version (%s/%s) found, but was missing "
                "when retrieved as tarball from %r."
                % (e.package, e.version, e.upstream),
                upstream_version=e.version)
            return 1
        except NoUpstreamLocationsKnown:
            report_fatal(
                "no-upstream-locations-known",
                "No debian/watch file or Repository in "
                "debian/upstream/metadata to retrieve new upstream version "
                "from.",
            )
            return 1
        except NewerUpstreamAlreadyImported as e:
            report_fatal(
                "newer-upstream-version-already-imported",
                "A newer upstream release (%s) has already been imported. "
                "Found: %s" % (e.old_upstream_version, e.new_upstream_version),
                upstream_version=e.new_upstream_version
            )
            return 1
        except WatchSyntaxError as e:
            report_fatal('watch-syntax-error', str(e))
            return 1
        except BigVersionJump as e:
            report_fatal(
                "big-version-jump",
                "There was a big jump in upstream versions: %s ⇒ %s" % (
                    e.old_upstream_version, e.new_upstream_version),
                upstream_version=e.new_upstream_version)
            return 1
        except OSError as e:
            if e.errno == errno.ENOSPC:
                report_fatal("no-space-on-device", str(e))
                return 1
            raise

        svp_context = {
            "old_upstream_version":
                str(result.old_upstream_version)
                if result.old_upstream_version else None,
            "upstream_version": str(result.new_upstream_version),
        }

        if result.upstream_branch:
            svp_context["upstream_branch_url"] = full_branch_url(
                result.upstream_branch)
            svp_context["upstream_branch_browse"] = result.upstream_branch_browse

        svp_context["include_upstream_history"] = result.include_upstream_history

        if args.import_only:
            logging.info(
                "Imported new upstream version %s (previous: %s)",
                result.new_upstream_version,
                result.old_upstream_version,
            )

            if os.environ.get('SVP_API') == '1':
                with open(os.environ['SVP_RESULT'], 'w') as f:
                    json.dump({
                        'description': "Imported new upstream version %s" % (
                            result.new_upstream_version),
                        'context': svp_context,
                        }, f)
            return 0
        else:
            logging.info(
                "Merged new upstream version %s (previous: %s)",
                result.new_upstream_version,
                result.old_upstream_version,
            )

            if args.update_packaging:
                old_tree = local_tree.branch.repository.revision_tree(
                    result.old_revision
                )
                notes = update_packaging(local_tree, old_tree, committer=committer)
                svp_context["notes"] = notes
                for n in notes:
                    logging.info("%s", n)

            patch_series_path = os.path.join(subpath, "debian/patches/series")
            if args.refresh_patches and local_tree.has_filename(patch_series_path):
                logging.info("Refresh quilt patches.")
                try:
                    refresh_quilt_patches(
                        local_tree,
                        old_version=result.old_upstream_version,
                        new_version=result.new_upstream_version,
                        committer=committer,
                        subpath=subpath,
                    )
                except QuiltError as e:
                    error_description = (
                        "An error (%d) occurred refreshing quilt patches: "
                        "%s%s" % (e.retcode, e.stderr, e.extra)
                    )
                    error_code = "quilt-refresh-error"
                    report_fatal(error_code, error_description)
                    return 1
                except QuiltPatchPushFailure as e:
                    error_description = (
                        "An error occurred refreshing quilt patch %s: %s"
                        % (e.patch_name, e.actual_error.extra)
                    )
                    error_code = "quilt-refresh-error"
                    report_fatal(error_code, error_description)
                    return 1

            logging.info(
                'Merge new upstream version %s', result.new_upstream_version)
            proposed_commit_message = (
                "Merge new upstream release %s" % result.new_upstream_version
            )
            if os.environ.get('SVP_API') == '1':
                with open(os.environ['SVP_RESULT'], 'w') as f:
                    json.dump({
                        'description': "Merged new upstream version %s" % (
                            result.new_upstream_version),
                        'commit-message': proposed_commit_message,
                        'context': svp_context,
                        }, f)


if __name__ == "__main__":
    import asyncio
    import sys

    sys.exit(asyncio.run(main()))
