#!/usr/bin/python3

# Copyright © 2013, 2014 Jakub Wilk <jwilk@debian.org>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the “Software”), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import argparse
import collections
import difflib
import errno
import functools
import os
import re
import subprocess as ipc
import sys

import apt_pkg

@functools.total_ordering
class debversion(str):
    def __lt__(self, other):
        return apt_pkg.version_compare(self, other) < 0
    def __eq__(self, other):
        return apt_pkg.version_compare(self, other) == 0

def log_start(s):
    print(s, end=' ... ')
    sys.stdout.flush()

def log_end(s):
    print(s)

def log_stderr(s):
    for line in s.splitlines():
        print('!', line)

class DpkgError(Exception):
    pass

class Uninstallable(DpkgError):
    pass

class DebPkg(object):

    def __init__(self, path):
        self.depends = self.pre_depends = ''
        self.conflicts = self.breaks = ''
        cmdline = [
            'dpkg-deb',
            '-f', path,
        ]
        output = ipc.check_output(cmdline)
        output = output.decode('ASCII').splitlines()
        attr = None
        def a_get():
            return getattr(self, attr, '')
        def a_set(value):
            return setattr(self, attr, value)
        def a_append(value):
            return setattr(self, attr, a_get() + '\n' + line)
        for line in output:
            if line[0] == ' ':
                a_append(line)
            else:
                key, value = line.split(': ', 1)
                attr = key.lower().replace('-', '_')
                a_set(value)
        self.path = path
        self.name = os.path.basename(path).split('_', 1)[0]

    def check_installability(self):
        depends = '{0.pre_depends}, {0.depends}'.format(self)
        conflicts = '{0.conflicts}, {0.breaks}'.format(self)
        cmdline = [
            'dpkg-checkbuilddeps',
            '-d', depends,
            '-c', conflicts,
            os.devnull
        ]
        child = ipc.Popen(cmdline, stdout=ipc.PIPE, stderr=ipc.PIPE)
        stdout, stderr = child.communicate()
        if child.wait() != 0:
            error = stderr.decode('ASCII', 'replace').strip()
            error = re.sub('^dpkg-checkbuilddeps: ', '', error)
            error = re.sub('^Unmet build dependencies', 'unmet dependencies', error)
            error = re.sub('^Build conflicts', 'conflicts', error)
            raise Uninstallable(error)

    def install(self):
        cmdline = ['dpkg', '-i', self.path]
        child = ipc.Popen(cmdline, stdout=ipc.PIPE, stderr=ipc.PIPE)
        stdout, stderr = child.communicate()
        if child.wait() != 0:
            stderr = stderr.decode('ASCII', 'replace')
            raise DpkgError(stderr)

    def purge(self):
        cmdline = ['dpkg', '-P', self.name]
        child = ipc.Popen(cmdline, stdout=ipc.PIPE, stderr=ipc.PIPE)
        stdout, stderr = child.communicate()
        if child.wait() != 0:
            stderr = stderr.decode('ASCII', 'replace')
            raise DpkgError(stderr)

    def get_version(self):
        return self.version

def format_os_error(exc):
    try:
        prefix = errno.errorcode[exc.args[0]]
    except KeyError:
        prefix = 'errno={}'.format(exc.args[0])
    return '{}: {}'.format(prefix, exc.args[1])

def main():
    apt_pkg.init()
    ap = argparse.ArgumentParser()
    ap.add_argument('paths', metavar='DEB-OR-CHANGES', nargs='+')
    ap.add_argument('--no-skip', action='store_true')
    ap.add_argument('--adequate', default='adequate')
    options = ap.parse_args()
    if os.getuid() != 0:
        print('{}: error: administrator privileges are required'.format(ap.prog), file=sys.stderr)
        sys.exit(1)
    packages = collections.defaultdict(list)
    for path in options.paths:
        if path.endswith('.changes'):
            with open(path, 'rt', encoding='UTF-8') as file:
                for para in apt_pkg.TagFile(file):
                    for line in para['Files'].splitlines():
                        md5, size, section, priority, path = line.split()
                        if path.endswith('.deb'):
                            debpkg = DebPkg(path)
                            packages[debpkg.name] += [debpkg]
        elif path.endswith('.deb'):
            debpkg = DebPkg(path)
            packages[debpkg.name] += [debpkg]
    rc = 0
    for name, debpkg_group in sorted(packages.items()):
        debpkg_group.sort(key=DebPkg.get_version)
        prev_version = None
        for debpkg in debpkg_group:
            if prev_version is None:
                if len(debpkg_group) == 1:
                    log_start('install {}'.format(name))
                else:
                    log_start('install {} ({})'.format(name, debpkg.version))
            else:
                log_start('upgrade {} ({} => {})'.format(name, prev_version, debpkg.version))
            try:
                debpkg.check_installability()
            except Uninstallable as exc:
                if options.no_skip:
                    action = 'ERROR'
                    rc = 1
                else:
                    action = 'SKIP'
                log_end('{} ({})'.format(action, exc))
                break
            try:
                debpkg.install()
            except DpkgError as exc:
                log_end('ERROR (dpkg failed)')
                log_stderr(str(exc))
                rc = 1
                break
            else:
                log_end('ok')
            log_start('check {}'.format(name))
            error = None
            try:
                child = ipc.Popen(
                    [options.adequate, name],
                    stdout=ipc.PIPE,
                    stderr=ipc.PIPE,
                )
            except OSError as exc:
                error = format_os_error(exc)
                stderr = b''
            else:
                stdout, stderr = child.communicate()
                if child.wait() != 0:
                    error = 'non-zero exit code'
                elif stderr:
                    error = 'non-empty stderr'
            if error is not None:
                log_end('ERROR ({})'.format(error))
                stderr = stderr.decode('ASCII', 'replace')
                log_stderr(stderr)
                rc = 1
                break
            output = stdout.decode('ASCII').splitlines()
            output = sorted(output)  # tag order is not deterministic
            if debpkg is debpkg_group[-1]:
                expected = [
                    '{}: {}'.format(name, line.strip())
                    for line in debpkg.description.splitlines(False)[1:]
                    if line
                ]
            else:
                expected = []
            if output == expected:
                log_end('ok')
            else:
                log_end('FAIL')
                diff = list(
                    difflib.unified_diff(expected, output, n=9999)
                )
                for line in diff[3:]:
                    print(line)
                rc = 1
            prev_version = debpkg.version
        log_start('remove {}'.format(name))
        try:
            debpkg.purge()
        except DpkgError as exc:
            log_end('ERROR (dpkg failed)')
            log_stderr(str(exc))
            rc = 1
        else:
            log_end('ok')
    sys.exit(rc)

if __name__ == '__main__':
    main()

# vim:ts=4 sts=4 sw=4 et
