#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Copyright © 2015-2017 Collabora Ltd.
#
# SPDX-License-Identifier: MPL-2.0
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

# deb-build-snapshot — Build snapshots of a package.
#
# Assumptions:
# * deb-git-version-gen is next to this script's physical location,
#   or elsewhere in PATH
# * The current working directory is the root of a git repository
# * The current branch has upstream sources and a debian/ directory
# * Debian packaging tags look like debian/1.2.3-4 (or vendor/1.2.3-4vendor5)
# * mktemp uses a location with plenty of space
#
# and with --upstream or --auto-upstream:
# * Upstream tags look like v1.2.3 or 1.2.3
# * either:
#   - The upstream project uses Autotools
#   - "NOCONFIGURE=1 ./autogen.sh" and "./configure" lead to a reasonable build
# * or:
#   - The upstream project uses Meson
#   - ninja dist leads to a reasonable build
# * Parallel builds (including Autotools distcheck) work

import abc
import argparse
import enum
import fnmatch
import glob
import json
import logging
import os
import shlex
import subprocess
import sys

try:
    import typing
except ImportError:
    pass
else:
    typing  # silence "unused" warnings

# python3-debian or python-debian is available in Debian, Fedora, etc.
from debian.debian_support import Version


logger = logging.getLogger('deb-build-snapshot')


class Failure(Exception):
    """An exception that does not normally provoke a traceback"""


class ShlexAndAppend(argparse.Action):
    def __call__(self, parser, namespace, values, option_string):
        value = getattr(namespace, self.dest, [])
        setattr(
            namespace,
            self.dest,
            list(value) + list(shlex.split(values)),
        )


class ExecutionEnvironment(metaclass=abc.ABCMeta):
    @staticmethod
    def to_shell(cmd, shell=False):
        # type: (typing.Union[str, typing.List[str]], bool) -> str
        """cmd is the first argument of subprocess.call:
        a sequence of argv elements if shell is false, or a single
        shell one-liner if shell is true. Return an equivalent
        shell one-liner, suitable for running via sh -c or ssh.
        """
        if shell:
            assert isinstance(cmd, str)
            return cmd
        else:
            return ' '.join((shlex.quote(x) for x in cmd))

    @staticmethod
    def to_argv(cmd, shell=False):
        # type: (typing.Union[str, typing.List[str]], bool) -> typing.List[str]
        """cmd is the first argument of subprocess.call:
        a sequence of argv elements if shell is false, or a single
        shell one-liner if shell is true. Return an equivalent
        argument vector.
        """
        if shell:
            assert isinstance(cmd, str)
            return ['sh', '-c', cmd]
        else:
            return list(cmd)

    @abc.abstractmethod
    def do(self, cmd, shell=False, may_fail=False):
        # type: (typing.Union[str, typing.List[str]], bool, bool) -> int
        """Log cmd and execute it.

        Return the exit status, or raise CalledProcessException if the
        exit status is a failure and may_fail is false.

        If shell is false, cmd is a sequence of argv elements;
        if true, cmd is a single string containing a shell one-liner.
        """

    @abc.abstractmethod
    def capture(self, cmd, shell=False):
        # type: (typing.Union[str, typing.List[str]], bool) -> str
        """Same as do(), but capture and return the stdout from cmd.
        """


class LocalExecutionEnvironment(ExecutionEnvironment):
    def __init__(self, *, debug=False):
        # type: (bool) -> None
        self.debug = debug

    def do(self, cmd, shell=False, may_fail=False):
        # type: (typing.Union[str, typing.List[str]], bool, bool) -> int
        logging.debug('%s', cmd)

        if may_fail:
            return subprocess.call(cmd, shell=shell)
        else:
            subprocess.check_call(cmd, shell=shell)
            return 0

    def capture(self, cmd, shell=False):
        # type: (typing.Union[str, typing.List[str]], bool) -> str
        logging.debug('%s', cmd)
        return subprocess.check_output(
            cmd,
            shell=shell,
            universal_newlines=True,
        )


class RemoteExecutionEnvironment(ExecutionEnvironment):
    def __init__(self, locally, builder):
        self.locally = locally
        self.builder = builder

    def do(self, cmd, shell=False, may_fail=False):
        # If we’re running on a TTY, run SSH in interactive mode, so that
        # the TERM variable is set correctly on the remote host and hence
        # we end up with compiler coloured output.
        interactive = '-t' if sys.stderr.isatty() else '-T'
        return self.locally.do([
            'ssh', interactive, self.builder,
            self.to_shell(cmd, shell)
        ], shell=False, may_fail=may_fail)

    def capture(self, cmd, shell=False):
        # type: (typing.Union[str, typing.List[str]], bool) -> str
        return self.locally.capture([
            'ssh', self.builder, self.to_shell(cmd, shell),
        ])


class SudoExecutionEnvironment(ExecutionEnvironment):
    def __init__(self, on_builder):
        # type: (ExecutionEnvironment) -> None
        self.on_builder = on_builder

    def do(self, cmd, shell=False, may_fail=False):
        # type: (typing.Union[str, typing.List[str]], bool, bool) -> int
        if shell:
            assert isinstance(cmd, str)
            return self.on_builder.do(
                [
                    'sudo', '--',
                    'sh', '-c', cmd,
                ],
                shell=False, may_fail=may_fail,
            )
        else:
            assert isinstance(cmd, list)
            return self.on_builder.do(
                ['sudo', '--'] + cmd,
                shell=False, may_fail=may_fail,
            )

    def capture(self, cmd, shell=False):
        # type: (typing.Union[str, typing.List[str]], bool) -> str
        if shell:
            assert isinstance(cmd, str)
            return self.on_builder.capture(
                [
                    'sudo', '--',
                    'sh', '-c', cmd,
                ],
                shell=False,
            )
        else:
            assert isinstance(cmd, list)
            return self.on_builder.capture(
                ['sudo', '--'] + cmd,
                shell=False,
            )


class DirExecutionEnvironment(ExecutionEnvironment):
    def __init__(self, on_builder, srcdir):
        # type: (ExecutionEnvironment, str) -> None
        self.on_builder = on_builder
        self.srcdir = srcdir

    def do(self, cmd, shell=False, may_fail=False):
        # type: (typing.Union[str, typing.List[str]], bool, bool) -> int
        return self.on_builder.do(
            'cd {} && {}'.format(
                shlex.quote(self.srcdir),
                self.to_shell(cmd, shell),
            ),
            shell=True, may_fail=may_fail,
        )

    def capture(self, cmd, shell=False):
        # type: (typing.Union[str, typing.List[str]], bool) -> str
        return self.on_builder.capture(
            'cd {} && {}'.format(
                shlex.quote(self.srcdir),
                self.to_shell(cmd, shell),
            ),
            shell=True,
        )


class SchrootExecutionEnvironment(ExecutionEnvironment):
    def __init__(self, on_builder, chroot):
        # type: (ExecutionEnvironment, str) -> None
        self.on_builder = on_builder
        self.chroot = chroot

    def do(self, cmd, shell=False, may_fail=False):
        # type: (typing.Union[str, typing.List[str]], bool, bool) -> int
        return self.on_builder.do(
            ['schroot', '-c', self.chroot, '--'] + self.to_argv(cmd, shell),
            may_fail=may_fail,
            shell=False,
        )

    def capture(self, cmd, shell=False):
        # type: (typing.Union[str, typing.List[str]], bool) -> str
        return self.on_builder.capture(
            ['schroot', '-c', self.chroot, '--'] + self.to_argv(cmd, shell),
            shell=False,
        )


SnapshotMarker = enum.Enum('SnapshotMarker', 'COUNTER DATE')
Upstreamness = enum.Enum('Upstreamness', 'UPSTREAM PACKAGING AUTO')


class SnapshotBuilder:
    def __init__(self, args):
        # type: (argparse.Namespace) -> None
        self.args = args
        self.srcdir = '.'
        self.locally = LocalExecutionEnvironment(debug=args.debug)
        self.on_builder = None  # type: typing.Optional[ExecutionEnvironment]
        self.in_srcdir = None   # type: typing.Optional[ExecutionEnvironment]
        self.upstream_env = None    # type: typing.Optional[ExecutionEnvironment]   # noqa
        self.deb_env = None     # type: typing.Optional[ExecutionEnvironment]
        self.root_deb_env = None    # type: typing.Optional[ExecutionEnvironment]   # noqa

        self.deb_git_version_gen = [
            sys.executable,
            os.path.join(
                os.path.dirname(os.path.realpath(sys.argv[0])),
                'deb-git-version-gen',
            )
        ]

        if not os.path.exists(self.deb_git_version_gen[1]):
            self.deb_git_version_gen = ['deb-git-version-gen']

    def do_merges(self):
        # type: () -> None
        assert self.in_srcdir is not None
        for r in self.args.remotes:
            name, uri = r.split('=', 1)
            self.in_srcdir.do(['git', 'remote', 'remove', name], may_fail=True)
            self.in_srcdir.do(['git', 'remote', 'add', name, uri])
            self.in_srcdir.do(['git', 'remote', 'update', '--prune', name])

        for m in self.args.merges:
            self.in_srcdir.do([
                'git', 'merge',
                '-s', self.args.merge_strategy,
                '-m', 'Automatic merge for snapshot',
                '--allow-unrelated-histories',
            ] + ['-X{}'.format(x) for x in self.args.merge_options] + [
                m,
            ])

    def set_srcdir(self, srcdir, tmpdir):
        # type: (str, str) -> None
        self.srcdir = srcdir
        self.in_srcdir = DirExecutionEnvironment(self.on_builder, self.srcdir)

        if self.args.schroot is not None:
            self.upstream_env = SchrootExecutionEnvironment(
                self.in_srcdir, self.args.schroot)
        else:
            self.upstream_env = self.in_srcdir

        if self.args.deb_schroot is not None:
            self.deb_env = SchrootExecutionEnvironment(
                self.in_srcdir, self.args.deb_schroot)
        else:
            self.deb_env = self.upstream_env

        if os.getuid() == 0:
            self.root_deb_env = self.deb_env
        else:
            self.root_deb_env = SudoExecutionEnvironment(self.deb_env)

    def main(self):
        # type: () -> None
        """Main function: build the snapshot.
        """
        if self.args.builder == 'localhost':
            self.on_builder = self.locally
        else:
            self.on_builder = RemoteExecutionEnvironment(
                self.locally, self.args.builder)

        tmpdir = self.on_builder.capture([
            'mktemp', '-d', '/tmp/build-snapshot.XXXXXXXXXX']).strip('\n')
        self.set_srcdir(tmpdir + '/s', tmpdir)

        if self.args.builder == 'localhost':
            self.locally.do([
                'rsync', '-az', '--delete', './', self.srcdir + '/'])
        else:
            self.locally.do([
                'rsync', '-az', '--delete', './',
                self.args.builder + ':' + self.srcdir + '/'])

        self.do_merges()

        # Check build-dependencies first, so we don't waste time running
        # autoreconf if it's going to fail.
        if not self.args.source_only and not self.args.print_version:
            self.deb_env.do(['dpkg-checkbuilddeps'])

            if self.args.i386:
                self.deb_env.do(['dpkg-checkbuilddeps', '-ai386', '-B'])

        source_package = self.in_srcdir.capture([
            'dpkg-parsechangelog', '-SSource']).strip('\n')

        argv = self.deb_git_version_gen + [
            '--json',
        ]

        if self.args.upstream is Upstreamness.AUTO:
            argv.append('--auto-upstream')
        elif self.args.upstream is Upstreamness.UPSTREAM:
            argv.append('--upstream')
        elif self.args.upstream is Upstreamness.PACKAGING:
            argv.append('--packaging-only')
        else:
            assert self.args.upstream is None

        if self.args.debug:
            argv.append('--debug')

        if self.args.release:
            argv.append('--release')
        elif self.args.release is None:
            argv.append('--guess-release')

        if self.args.packaging_tag:
            argv.append('--packaging-tag=' + self.args.packaging_tag)
        if self.args.vendor:
            argv.append('--vendor=' + self.args.vendor)

        if self.args.snapshot_marker is SnapshotMarker.DATE:
            argv.append('--date-based')
        elif self.args.snapshot_marker is SnapshotMarker.COUNTER:
            argv.append('--counter-based')
        else:
            assert self.args.snapshot_marker is None

        if self.args.branch_marker is not None:
            argv.append('--branch-marker')
            argv.append(self.args.branch_marker)

        if self.args.build_suffix is not None:
            argv.append('--build-suffix')
            argv.append(self.args.build_suffix)

        if self.args.avoid_build_suffix is not None:
            argv.append('--avoid-build-suffix')
            argv.append(self.args.avoid_build_suffix)

        try:
            text = self.locally.capture(argv)
        except subprocess.CalledProcessError as e:
            if e.returncode == 1 and not self.args.debug:
                raise Failure('deb-git-version-gen failed')
            else:
                raise

        logging.debug('%s', text)
        version_info = json.loads(text)
        is_native = version_info['is_native']
        is_upstream = version_info['is_upstream']
        snapshot_version = Version(version_info['snapshot_version'])

        if self.args.upstream is Upstreamness.PACKAGING and is_upstream:
            raise AssertionError(
                'deb-git-version-gen --packaging returned is_upstream:true'
            )

        if '/' in str(snapshot_version):
            raise Failure('Bad version {!r}'.format(snapshot_version))
        if '/' in source_package:
            raise Failure(
                'Bad package name {!r}'.format(source_package))

        if self.args.print_version:
            print(snapshot_version)
            self.on_builder.do(['rm', '-rf', shlex.quote(tmpdir)])
            return

        srcdir = '{}/{}-{}'.format(
            tmpdir,
            source_package,
            snapshot_version,
        )
        self.on_builder.do(['mv', tmpdir + '/s', srcdir])
        self.set_srcdir(srcdir, tmpdir)

        path = self.on_builder.capture('echo $PATH', shell=True).strip('\n')
        if ':/usr/lib/ccache:' not in ':{}:'.format(path):
            path = '/usr/lib/ccache:{}'.format(path)

        aclocal_path = []

        for i, d in enumerate(self.args.aclocal_search):
            if self.args.builder == 'localhost':
                remote = d
            else:
                remote = tmpdir + '/aclocal.{}'.format(i)
                self.locally.do([
                    'rsync', '-az', '--delete', d + '/',
                    self.args.builder + ':' + remote + '/',
                ])

            aclocal_path.append(remote)

        for d in self.args.aclocal_search_builder:
            aclocal_path.append(d)

        is_autotools = os.path.exists('configure.ac')
        is_meson = os.path.exists('meson.build')
        dist_tar_dir = None     # type: typing.Optional[str]

        # Assume that packages with /configure.ac are Autotools.
        # We probe for configure.ac instead of testing for is_native,
        # because we could conceivably have native packages that use
        # Autotools and we want to exercise distcheck for those;
        # we don't do this unconditionally because we have some native
        # packages that don't use Autotools.
        if (
            is_upstream and
            not is_native and
            (is_meson or is_autotools) and
            # If we want a source package, we must at least `make dist`.
            # If we want to do build-time tests, we presumably want to
            # also verify that it distchecks. However, if we just want
            # binary packages as fast as possible (--no-check),
            # skip it.
            (self.args.source or self.args.check)
        ):
            if os.path.exists('.git'):
                self.in_srcdir.do(['git', 'status', '-u'])
                if self.args.git_clean:
                    self.in_srcdir.do(['git', 'clean', '-fxd'])

            if is_autotools:
                self.upstream_env.do([
                    'env',
                    'ACLOCAL_PATH=' + ':'.join(aclocal_path),
                    'NOCONFIGURE=1',
                    './autogen.sh',
                ])
                self.upstream_env.do(
                    ['./configure'] + self.args.configure_options)

                if self.args.check:
                    self.upstream_env.do([
                        'env', 'PATH={}'.format(path), 'make',
                        '-j{}'.format(self.args.jobs), 'check',
                        'VERBOSE=1',
                    ])
                    self.upstream_env.do([
                        'find', '.', '-name', 'test-suite.log', '-exec',
                        'head', '-v', '-n10000', '{}', ';'])
                    self.upstream_env.do([
                        'env', 'PATH={}'.format(path), 'make',
                        '-j{}'.format(self.args.jobs), 'distcheck',
                        'VERBOSE=1',
                    ])
                else:
                    if not self.args.source_only:
                        self.upstream_env.do([
                            'env', 'PATH={}'.format(path), 'make',
                            '-j{}'.format(self.args.jobs), 'VERBOSE=1',
                        ])

                    self.upstream_env.do([
                        'env', 'PATH={}'.format(path), 'make',
                        '-j{}'.format(self.args.jobs), 'dist', 'VERBOSE=1',
                    ])

                dist_tar_dir = self.srcdir
            elif is_meson:
                self.upstream_env.do(
                    ['meson'] + self.args.configure_options + ['../builddir'])
                self.upstream_env.do([
                    'env', 'PATH={}'.format(path),
                    'ninja',
                    '-j{}'.format(self.args.jobs),
                    '-C', '../builddir',
                ])

                if self.args.check:
                    self.upstream_env.do([
                        'env', 'PATH={}'.format(path),
                        'meson',
                        'test',
                        '-C', '../builddir',
                        '-v',
                    ])

                self.upstream_env.do([
                    'env', 'PATH={}'.format(path),
                    'ninja',
                    '-j{}'.format(self.args.jobs),
                    '-C', '../builddir',
                    'dist',
                ])

                dist_tar_dir = tmpdir + '/builddir/meson-dist'
            else:
                raise AssertionError('Cannot dist non-Autotools non-Meson')

        if self.args.source and not is_native:
            if dist_tar_dir is not None:
                tarballs = self.in_srcdir.capture(
                    'ls -1 {}/*.tar.*'.format(shlex.quote(dist_tar_dir)),
                    shell=True,
                ).strip().splitlines()
                got_tarballs = False

                for tarball in tarballs:
                    for ext in '.tar.xz', '.tar.gz':
                        if tarball.endswith(ext):
                            self.in_srcdir.do('mv {} ../{}_{}.orig{}'.format(
                                tarball, shlex.quote(source_package),
                                shlex.quote(
                                    snapshot_version.upstream_version
                                ),
                                ext),
                                shell=True)
                            got_tarballs = True

                if not got_tarballs:
                    raise Failure(
                        'No supported tarball found in {}'.format(
                            tarballs,
                        ),
                    )

            else:
                tarball_format = '{}_{}.orig*.tar.*'.format(
                    source_package, snapshot_version.upstream_version)
                tarballs = glob.glob('{}/{}'.format(
                    self.args.orig_dir, tarball_format))
                got_tarballs = False

                if tarballs:
                    extra_format = '{}_{}.orig-*.tar.*'.format(
                        source_package, snapshot_version.upstream_version)
                    extras = glob.glob('{}/{}'.format(
                        self.args.orig_dir, extra_format))
                    tarballs = tarballs + extras

                    if self.args.builder == 'localhost':
                        dest = '{}/'.format(tmpdir)
                    else:
                        dest = '{}:{}/'.format(self.args.builder, tmpdir)

                    for t in tarballs:
                        self.locally.do([
                            'rsync', '-P', '--copy-links', t, dest])

                    got_tarballs = True

                if not got_tarballs:
                    try:
                        self.in_srcdir.do([
                            'git', 'branch', 'pristine-tar',
                            'origin/pristine-tar'])
                    except subprocess.CalledProcessError:
                        # assume branch already exists
                        pass
                    try:
                        for line in self.in_srcdir.capture([
                            'pristine-tar',
                            'list'
                        ]).splitlines():
                            if fnmatch.fnmatch(line, tarball_format):
                                self.in_srcdir.do([
                                    'pristine-tar', 'checkout', line])
                                self.in_srcdir.do(['mv', line, '..'])
                                got_tarballs = True
                    except subprocess.CalledProcessError:
                        pass

                if not got_tarballs:
                    self.on_builder.do(
                        'cd {} && apt-get --download-only source {}'.format(
                            shlex.quote(tmpdir),
                            shlex.quote(source_package),
                        ), shell=True)

        if os.path.exists('.git'):
            self.in_srcdir.do(['git', 'status', '-u'])
            if self.args.git_clean:
                self.in_srcdir.do(['git', 'clean', '-fxd'])

        if self.args.overlay:
            tarball = self.in_srcdir.capture(
                'ls -1 ../{}_{}.orig.tar.*'.format(
                    source_package,
                    snapshot_version.upstream_version,
                ),
                shell=True)
            tarball = tarball.strip()

            if '\n' in tarball:
                raise Failure(
                    'Cannot unpack multiple tarballs for --overlay: '
                    '{!r}'.format(tarball.split('\n')))
            elif not tarball:
                raise Failure('No tarballs found for --overlay')

            self.in_srcdir.do([
                'tar',
                '-x',
                '-f', tarball,
                '--auto-compress',
                '--strip-components=1',
                '--anchored',
                '--no-wildcards-match-slash',
                '--exclude=*/debian',
            ])
            self.in_srcdir.do(['git', 'status', '-u'])

        for command in self.args.before_dpkg_buildpackage:
            self.deb_env.do(command, shell=True)

        # If the version in the changelog is not already what we want,
        # make it so
        if 'dch' in version_info:
            self.in_srcdir.do(version_info['dch'], shell=True)

        args = ['-us', '-uc', '-i', '-I']

        if self.args.source_only:
            build_what = ['-S', '-d']
        elif self.args.source:
            build_what = []
        else:
            build_what = ['-b']

        deb_build_options = set(self.args.deb_build_options)

        if not self.args.check:
            deb_build_options.add('nocheck')

        self.dpkg_version = self.deb_env.capture([
            'dpkg-query', '-W', '-f${Version}', 'dpkg',
        ])

        self.apt_version = self.deb_env.capture([
            'dpkg-query', '-W', '-f${Version}', 'apt',
        ])

        if self.dpkg_version >= Version('1.18.2'):
            args.append('-J{}'.format(self.args.jobs))

        args.extend(self.args.dpkg_buildpackage_options)

        self.deb_env.do([
            'env',
            'ACLOCAL_PATH=' + ':'.join(aclocal_path),
            'DEB_BUILD_OPTIONS={}'.format(' '.join(deb_build_options)),
            'PATH={}'.format(path),
            'dpkg-buildpackage'] + build_what + args)
        self.in_srcdir.do([
            'find', '.', '-name', 'test-suite.log', '-exec',
            'head', '-v', '-n10000', '{}', ';'])

        if os.path.exists('.git'):
            self.in_srcdir.do(['git', 'status', '-u'])

        if self.args.i386 and not self.args.source_only:
            if os.path.exists('.git'):
                if self.args.git_clean:
                    self.in_srcdir.do(['git', 'clean', '-fxd'])

            self.deb_env.do([
                'env',
                'ACLOCAL_PATH=' + ':'.join(aclocal_path),
                'CC=gcc -m32',
                'DEB_BUILD_OPTIONS={}'.format(' '.join(deb_build_options)),
                'PATH={}'.format(path),
                'dpkg-buildpackage', '-ai386', '-B'] + args)
            self.in_srcdir.do([
                'find', '.', '-name', 'test-suite.log', '-exec',
                'head', '-v', '-n10000', '{}', ';'])

            if os.path.exists('.git'):
                self.in_srcdir.do(['git', 'status', '-u'])

        if not self.args.source_only and self.deb_env.do(
            'command -v debc >/dev/null', shell=True, may_fail=True
        ) == 0:
            self.deb_env.do('debc ../*.changes || :', shell=True)

            if self.args.i386:
                self.deb_env.do('debc -ai386 ../*.changes || :', shell=True)

        if self.args.download is not None:
            if self.args.builder != 'localhost':
                self.locally.do([
                    'rsync', '-zvP',
                    '{}:{}/*'.format(self.args.builder, tmpdir),
                    '{}/'.format(self.args.download)])
            else:
                self.locally.do([
                    'rsync', '-zvP',
                ] + list(glob.glob('{}/*'.format(tmpdir))) + [
                    '{}/'.format(self.args.download)
                ])

        if ((self.args.install or self.args.install_all) and
                not self.args.source_only):
            if (self.args.builder == 'localhost' and
                    not self.args.force_local_install):
                raise SystemExit(
                    'Refusing to install built packages locally without '
                    '--force-local-install')

            if self.apt_version >= Version('1.1'):
                install = 'apt install'

                if self.args.install_all:
                    install = install + ' --allow-downgrades -y'
                else:
                    install = install + ' --only-upgrade'

                install = install + ' {}/*.deb'.format(shlex.quote(tmpdir))

                self.root_deb_env.do(install, shell=True)
            elif self.deb_env.do(
                'command -v debi >/dev/null', shell=True, may_fail=True
            ) == 0:
                debi = ['debi', '--no-conf', '--with-depends',
                        '--debs-dir', tmpdir]

                if not self.args.install_all:
                    debi.append('--upgrade')

                self.root_deb_env.do(debi)
            else:
                install = 'dpkg -i'

                if not self.args.install_all:
                    install = install + ' -O'

                self.root_deb_env.do(
                    '{} {}/*.deb'.format(install, shlex.quote(tmpdir)),
                    shell=True)
                self.root_deb_env.do(['apt-get', '-f', 'install'])

            if (os.path.exists('debian/tests/control') and
                    self.deb_env.do(
                        'command -v sadt >/dev/null', shell=True, may_fail=True
            ) == 0):
                # sadt might do a compile-test, so use ccache here too
                self.deb_env.do([
                    'env', 'PATH={}'.format(path), 'sadt', '--verbose',
                    '--built-source-tree',
                ])

        if not self.args.keep:
            self.on_builder.do(['rm', '-rf', shlex.quote(tmpdir)])


def main():
    # type: () -> None

    logging.getLogger().setLevel(logging.INFO)

    if sys.stderr.isatty():
        try:
            import colorlog
        except ImportError:
            pass
        else:
            formatter = colorlog.ColoredFormatter(
                '%(log_color)s%(levelname)s:%(name)s:%(reset)s %(message)s')
            handler = logging.StreamHandler()
            handler.setFormatter(formatter)
            logging.getLogger().addHandler(handler)

    # This is a no-op if we already attached a (coloured log) handler
    logging.basicConfig()

    parser = argparse.ArgumentParser(
        description='Make a snapshot of a package')

    parser.add_argument(
        '--source', '-s',
        help='Build a source package (default: in-tree binary-only build)',
        action='store_true', default=False)
    parser.add_argument(
        '--source-only', '-S',
        help='Build a source package only',
        action='store_true', default=False, dest='source_only')
    parser.add_argument(
        '--dpkg-buildpackage-option', '--debuild-option',
        help='Pass arbitrary option to dpkg-buildpackage',
        action='append', dest='dpkg_buildpackage_options', default=[])
    parser.add_argument(
        '--i386', help='Build i386 binaries too',
        action='store_true', default=False)

    parser.add_argument(
        '--no-git-clean',
        help='do not run `git clean`',
        action='store_false', dest='git_clean', default=True)

    parser.add_argument(
        '--no-check', '--nocheck',
        help='do not run build-time tests',
        action='store_false', dest='check', default=True)
    parser.add_argument(
        '--build-option', '-O',
        help='append this to DEB_BUILD_OPTIONS (noopt, nocheck etc.)',
        action='append', dest='deb_build_options', default=[])

    parser.add_argument(
        '--before-dpkg-buildpackage',
        help='run shell commands before dpkg-buildpackage, for example '
             '"debian/rules debian/control || debian/rules debian/control" '
             'for src:linux; may be repeated to add more commands',
        action='append', default=[])

    parser.add_argument(
        '--packaging', '--packaging-only', '-p',
        help=(
            'assume that we are only packaging this package '
            '(requires orig tarball, pristine-tar branch or ability to '
            'obtain orig tarballs from `apt-get source`)'
        ),
        action='store_const', dest='upstream',
        const=Upstreamness.PACKAGING,
        default=None,
    )
    parser.add_argument(
        '--upstream', '-u',
        help='assume that we are the upstream for this package (default)',
        action='store_const',
        const=Upstreamness.UPSTREAM,
        default=None,
    )
    parser.add_argument(
        '--auto-upstream',
        help=(
            'assume that we are the upstream for this package, '
            'but do pure packaging changes as packaging revisions '
            '(requires orig tarball, pristine-tar branch or ability to '
            'obtain orig tarballs from `apt-get source`)'
        ),
        action='store_const', dest='upstream',
        const=Upstreamness.AUTO,
        default=None,
    )
    parser.add_argument(
        '--release', help='make a real release, not a snapshot',
        action='store_true', default=False)
    parser.add_argument(
        '--guess-release',
        help='make a real release if we are currently at a tag',
        action='store_const', const=None, dest='release')
    parser.add_argument(
        '--orig-dir',
        help='with -p, look for orig.tar.gz here (default: same as '
             '--download, or "..")',
        default=None)
    parser.add_argument(
        '--overlay',
        help='unpack orig.tar.* into source directory before build',
        action='store_true', default=False)

    parser.add_argument(
        '--packaging-tag', '--debian-tag',
        help='Format for git tags corresponding to a packaging version',
        default=None)
    parser.add_argument(
        '--vendor', '--distro', help='DEP-14 vendor/distribution string',
        default=None)

    parser.add_argument(
        '--counter-based',
        help='base version numbers on commit count (default)',
        action='store_const', dest='snapshot_marker',
        const=SnapshotMarker.COUNTER,
        default=None,
    )
    parser.add_argument(
        '--date-based', help='base version numbers on build date',
        action='store_const', dest='snapshot_marker',
        const=SnapshotMarker.DATE,
        default=None,
    )

    parser.add_argument(
        '--branch-marker', help='add this string to snapshot marker',
        default=None,
    )
    parser.add_argument(
        '--build-suffix',
        help=(
            'ensure snapshot marker is greater than this, e.g. "+b" for '
            'Debian binNMUs'
        ),
        default=None,
    )
    parser.add_argument(
        '--avoid-build-suffix',
        help=(
            'use this marker to avoid being less than --build-suffix, '
            'e.g. "+snapshot" (default: same as --build-suffix)'
        ),
        default=None,
    )

    parser.add_argument(
        '--keep', '-k',
        help='Keep temporary built tree after a successful build',
        action='store_true', default=False)
    parser.add_argument(
        '--download', '-d',
        help='Download build products to DOWNLOAD/ (existing files will '
             'be overwritten; gbp users might use "../build-area")',
        default=None)
    parser.add_argument(
        '--install', '-i',
        help='Upgrade build products on remote host',
        action='store_true', default=False)
    parser.add_argument(
        '--force-local-install',
        help='Force -i or -I to work even if building on localhost',
        action='store_true', default=False)
    parser.add_argument(
        '--install-all', '-I',
        help='Install build products on remote host even if not already '
             'installed',
        action='store_true', default=False)

    parser.add_argument(
        '--configure-option', metavar='OPTION', dest='configure_options',
        action='append', default=[],
        help='Pass OPTION to ./configure or meson (may be repeated), '
             'for example --configure-option=--prefix=/usr',
    )
    parser.add_argument(
        '--configure-options', metavar='OPTIONS', dest='configure_options',
        action=ShlexAndAppend,
        help='Split OPTIONS as if for a shell and pass them all to '
             './configure or meson, for example '
             '--configure-options="--prefix=/usr CC=\"ccache gcc\""',
    )
    parser.add_argument(
        '-j', '--jobs', type=int,
        help='Run this many jobs in parallel', default=5)

    parser.add_argument(
        '--aclocal-search-builder', metavar='DIR', action='append',
        default=[],
        help='Look for updated Autoconf m4 macros in DIR on HOSTNAME')
    parser.add_argument(
        '--aclocal-search', metavar='DIR', action='append',
        default=[],
        help='Look for updated Autoconf m4 macros in DIR on machine '
        'where deb-build-snapshot was invoked')

    parser.add_argument(
        '--merge', action='append', metavar='COMMIT',
        dest='merges', default=[],
        help='merge Git branches or tags, for example "--merge origin/wip"')
    parser.add_argument(
        '--merge-strategy', metavar='STRATEGY',
        dest='merge_strategy', default='recursive',
        help='Use "git merge -s STRATEGY"')
    parser.add_argument(
        '--merge-option', metavar='OPTION',
        dest='merge_options', action='append', default=[],
        help='Use "git merge -X OPTION [-X OPTION2...]"')
    parser.add_argument(
        '--add-remote', action='append', metavar='NAME=URI',
        dest='remotes', default=[],
        help='add extra Git remotes for use with --merge, in the format '
             '"--add-remote upstream=https://git.example.com/hello"')

    parser.add_argument(
        '--schroot', metavar='CHROOT', default=None,
        help='Enter CHROOT on remote host (it must share $TMPDIR with '
             'the host system)')
    parser.add_argument(
        '--deb-schroot', metavar='CHROOT', default=None,
        help='Enter CHROOT on remote host for Debian package build only')

    parser.add_argument(
        'builder', metavar='HOSTNAME',
        help='Remote host to use for the build (no default, use '
             '"localhost" for a local build)')

    parser.add_argument(
        '--print-version',
        help='Print the version number that would be used, then exit',
        action='store_true', default=False)

    parser.add_argument(
        '--debug',
        help='Give more diagnostics',
        action='store_true')

    args = parser.parse_args()

    if args.debug:
        logging.getLogger().setLevel(logging.DEBUG)

    if args.orig_dir is None:
        if args.download is None:
            args.orig_dir = '..'
        else:
            args.orig_dir = args.download

    if args.source_only:
        args.source = True

    try:
        SnapshotBuilder(args).main()
    except (Failure, subprocess.CalledProcessError) as e:
        if args.debug:
            raise
        else:
            logger.error('%s', e)
            sys.exit(1)


if __name__ == '__main__':
    main()
