#!/usr/bin/python3

import os
import sys
import unittest
import tempfile
import shutil
import io
import subprocess

try:
    # Python >= 3.3
    from unittest.mock import patch
    patch  # pyflakes
except ImportError:
    # fall back to separate package
    from mock import patch      # type: ignore

test_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(1, os.path.join(os.path.dirname(test_dir), 'lib'))

import adtlog
import testdesc


have_autodep8 = subprocess.call(['sh', '-ec', 'command -v autodep8'], stdout=subprocess.PIPE,
                                stderr=subprocess.PIPE) == 0
dpkg_deps_ver = subprocess.check_output(['perl', '-MDpkg::Deps', '-e', 'print $Dpkg::Deps::VERSION'],
                                        universal_newlines=True)
have_dpkg_build_profiles = (dpkg_deps_ver >= '1.04')


class Rfc822(unittest.TestCase):
    def test_control(self):
        '''Parse a debian/control like file'''

        control = tempfile.NamedTemporaryFile(prefix='control.')
        control.write('''Source: foo
Maintainer: Üñïcøδ€ <u@x.com>
Build-Depends: bd1, # moo
  bd2,
  bd3,
XS-Testsuite: autopkgtest
'''.encode())
        control.flush()
        parser = testdesc.parse_rfc822(control.name)
        r = parser.__next__()
        self.assertRaises(StopIteration, parser.__next__)
        control.close()

        self.assertEqual(r['Source'], 'foo')
        self.assertEqual(r['Xs-testsuite'], 'autopkgtest')
        self.assertEqual(r['Maintainer'], 'Üñïcøδ€ <u@x.com>')
        self.assertEqual(r['Build-depends'], 'bd1, bd2, bd3,')

    def test_dsc(self):
        '''Parse a signed dsc file'''

        control = tempfile.NamedTemporaryFile(prefix='dsc.')
        control.write('''-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256

Format: 3.0 (quilt)
Source: foo
Binary: foo-bin, foo-doc
Package-List:
 foo-bin deb utils optional arch=any
 foo-doc deb doc extra arch=all
Files:
 deadbeef 10000 foo_1.orig.tar.gz
 11111111 1000 foo_1-1.debian.tar.xz

-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1

BloB11
-----END PGP SIGNATURE-----

'''.encode())
        control.flush()
        parser = testdesc.parse_rfc822(control.name)
        r = parser.__next__()
        self.assertRaises(StopIteration, parser.__next__)
        control.close()

        self.assertEqual(r['Format'], '3.0 (quilt)')
        self.assertEqual(r['Source'], 'foo')
        self.assertEqual(r['Binary'], 'foo-bin, foo-doc')
        self.assertEqual(r['Package-list'], ' foo-bin deb utils optional arch=any'
                         ' foo-doc deb doc extra arch=all')
        self.assertEqual(r['Files'], ' deadbeef 10000 foo_1.orig.tar.gz'
                         ' 11111111 1000 foo_1-1.debian.tar.xz')

    def test_invalid(self):
        '''Parse an invalid file'''

        control = tempfile.NamedTemporaryFile(prefix='bogus.')
        control.write('''Bo Gus: something
muhaha'''.encode())
        control.flush()
        parser = testdesc.parse_rfc822(control.name)
        self.assertRaises(StopIteration, parser.__next__)
        control.close()


class Test(unittest.TestCase):
    def test_valid_path(self):
        '''valid Test instantiation with path'''

        t = testdesc.Test('foo', 'tests/do_foo', None, ['needs-root'],
                          ['unknown_feature'], ['coreutils >= 7'], [])
        self.assertEqual(t.name, 'foo')
        self.assertEqual(t.path, 'tests/do_foo')
        self.assertEqual(t.command, None)
        self.assertEqual(t.result, None)

    def test_valid_command(self):
        '''valid Test instantiation with command'''

        t = testdesc.Test('foo', None, 'echo hi', ['needs-root'],
                          ['unknown_feature'], ['coreutils >= 7'], [])
        self.assertEqual(t.name, 'foo')
        self.assertEqual(t.path, None)
        self.assertEqual(t.command, 'echo hi')
        self.assertEqual(t.result, None)

    def test_invalid_name(self):
        '''Test with invalid name'''

        with self.assertRaises(testdesc.Unsupported) as cm:
            testdesc.Test('foo/bar', 'do_foo', None, [], [], [], [])
        self.assertIn('may not contain /', str(cm.exception))

    def test_unknown_restriction(self):
        '''Test with unknown restriction'''

        testdesc.Test('foo', 'tests/do_foo', None, ['needs-red'], [], [], [])

    def test_neither_path_nor_command(self):
        '''Test without path nor command'''

        with self.assertRaises(testdesc.InvalidControl) as cm:
            testdesc.Test('foo', None, None, [], [], [], [])
        self.assertIn('either path or command', str(cm.exception))

    def test_both_path_and_command(self):
        '''Test with path and command'''

        with self.assertRaises(testdesc.InvalidControl) as cm:
            testdesc.Test('foo', 'do_foo', 'echo hi', [], [], [], [])
        self.assertIn('either path or command', str(cm.exception))

    def test_capabilities_compat(self):
        '''Test compatibility with testbed capabilities'''

        t = testdesc.Test('foo', 'tests/do_foo', None,
                          ['needs-root', 'isolation-container'], [], [], [])

        self.assertRaises(testdesc.Unsupported,
                          t.check_testbed_compat, ['isolation-container'])
        self.assertRaises(testdesc.Unsupported,
                          t.check_testbed_compat, ['root-on-testbed'])
        t.check_testbed_compat(['isolation-container', 'root-on-testbed'])
        self.assertRaises(testdesc.Unsupported,
                          t.check_testbed_compat, ['needs-quantum-computer'])
        t.check_testbed_compat([],
                               ignore_restrictions=['needs-root',
                                                    'isolation-container'])


class Debian(unittest.TestCase):
    def setUp(self):
        self.pkgdir = tempfile.mkdtemp(prefix='testdesc.')
        os.makedirs(os.path.join(self.pkgdir, 'debian', 'tests'))
        self.addCleanup(shutil.rmtree, self.pkgdir)

    def call_parse(self, testcontrol, pkgcontrol=None, caps=[]):
        if testcontrol:
            with open(os.path.join(self.pkgdir, 'debian', 'tests', 'control'), 'w', encoding='UTF-8') as f:
                f.write(testcontrol)
        if pkgcontrol:
            with open(os.path.join(self.pkgdir, 'debian', 'control'), 'w', encoding='UTF-8') as f:
                f.write(pkgcontrol)
        return testdesc.parse_debian_source(self.pkgdir, caps, 'amd64')

    def test_bad_recommends(self):
        with open(os.path.join(self.pkgdir, 'debian', 'control'), 'w', encoding='UTF-8') as f:
            f.write('''
Source: bla

Package: bli
Architecture: all
Recommends: ${package:one}, two (<= 10) <!nocheck>, three (<= ${ver:three}~), four ( <= 4 )
        ''')
        (ts, skipped) = self.call_parse(
            'Tests: one\nDepends: @recommends@')
        self.assertEqual(ts[0].depends, ['two (<= 10)', 'three', 'four (<= 4)'])

    def test_no_control(self):
        '''no test control file'''

        (ts, skipped) = self.call_parse(None, 'Source: foo\n')
        self.assertEqual(ts, [])
        self.assertFalse(skipped)

    def test_single(self):
        '''single test, simplest possible'''

        (ts, skipped) = self.call_parse('Tests: one\nDepends:')
        self.assertEqual(len(ts), 1)
        t = ts[0]
        self.assertEqual(t.name, 'one')
        self.assertEqual(t.path, 'debian/tests/one')
        self.assertEqual(t.command, None)
        self.assertEqual(t.restrictions, set())
        self.assertEqual(t.features, set())
        self.assertEqual(t.depends, [])
        self.assertFalse(skipped)

    def test_default_depends(self):
        '''default Depends: is @'''

        (ts, skipped) = self.call_parse(
            'Tests: t1 t2',
            'Source: nums\n\nPackage: one\nArchitecture: any\n\n'
            'Package: two\nPackage-Type: deb\nArchitecture: all\n\n'
            'Package: two-udeb\nXC-Package-Type: udeb\nArchitecture: any\n\n'
            'Package: three-udeb\nPackage-Type: udeb\nArchitecture: any')
        self.assertEqual(len(ts), 2)
        self.assertEqual(ts[0].name, 't1')
        self.assertEqual(ts[0].path, 'debian/tests/t1')
        self.assertEqual(ts[1].name, 't2')
        self.assertEqual(ts[1].path, 'debian/tests/t2')
        for t in ts:
            self.assertEqual(t.restrictions, set())
            self.assertEqual(t.features, set())
            self.assertEqual(t.depends, ['one', 'two'])
        self.assertFalse(skipped)

    def test_arch_specific(self):
        '''@ expansion with architecture specific binaries'''

        (ts, skipped) = self.call_parse(
            'Tests: t',
            'Source: nums\n\nPackage: one\nArchitecture: any\n\n'
            'Package: two\nArchitecture: linux-any\n\n'
            'Package: three\nArchitecture: c64 vax')
        self.assertEqual(len(ts), 1)
        self.assertEqual(ts[0].name, 't')
        self.assertEqual(ts[0].path, 'debian/tests/t')
        self.assertEqual(ts[0].restrictions, set())
        self.assertEqual(ts[0].features, set())
        self.assertEqual(ts[0].depends,
                         ['one', 'two [linux-any]',
                          'three [c64 vax]'])
        self.assertFalse(skipped)

    def test_test_name_feature(self):
        '''Features: test-name=foobar'''

        (ts, skipped) = self.call_parse(
            'Test-Command: t1\n'
            'Depends: foo\n'
            'Features: test-name=foobar')
        self.assertEqual(len(ts), 1)
        self.assertEqual(ts[0].features, {'test-name=foobar'})
        self.assertEqual(ts[0].name, 'foobar')
        self.assertFalse(skipped)

    def test_test_name_feature_too_many(self, *args):
        '''only one test-name= feature is allowed'''

        with self.assertRaises(testdesc.InvalidControl) as cm:
            self.call_parse(
                'Test-Command: t1\n'
                'Depends: foo\n'
                'Features: test-name=foo,test-name=bar')
        self.assertEqual(
            str(cm.exception),
            'InvalidControl test *: only one test-name feature allowed')

    def test_test_name_feature_with_other_features(self):
        '''Features: test-name=foobar, blue'''

        (ts, skipped) = self.call_parse(
            'Test-Command: t1\n'
            'Depends: foo\n'
            'Features: test-name=foo,blue')
        self.assertEqual(len(ts), 1)
        self.assertEqual(ts[0].features, {'test-name=foo', 'blue'})
        self.assertFalse(skipped)

    def test_test_name_missing_name(self, *args):
        '''Features: test-name'''

        with self.assertRaises(testdesc.InvalidControl) as cm:
            self.call_parse(
                'Test-Command: t1\n'
                'Depends: foo\n'
                'Features: test-name')
        self.assertEqual(
            str(cm.exception),
            'InvalidControl test *: test-name feature with no argument')

    def test_test_name_incompatible_with_tests(self, *args):
        '''Tests: with Features: test-name=foo'''

        with self.assertRaises(testdesc.InvalidControl) as cm:
            self.call_parse(
                'Tests: t1\n'
                'Depends: foo\n'
                'Features: test-name=foo')
        self.assertEqual(
            str(cm.exception),
            'InvalidControl test *: test-name feature incompatible with Tests')

    def test_known_restrictions(self):
        '''known restrictions'''

        (ts, skipped) = self.call_parse(
            'Tests: t1 t2\nDepends: foo\nRestrictions: build-needed allow-stderr\nFeatures: blue\n\n'
            'Tests: three\nDepends:\nRestrictions: needs-recommends')
        self.assertEqual(len(ts), 3)

        self.assertEqual(ts[0].name, 't1')
        self.assertEqual(ts[0].restrictions, {'build-needed', 'allow-stderr'})
        self.assertEqual(ts[0].features, {'blue'})
        self.assertEqual(ts[0].depends, ['foo'])

        self.assertEqual(ts[1].name, 't2')
        self.assertEqual(ts[1].restrictions, {'build-needed', 'allow-stderr'})
        self.assertEqual(ts[1].features, {'blue'})
        self.assertEqual(ts[1].depends, ['foo'])

        self.assertEqual(ts[2].name, 'three')
        self.assertEqual(ts[2].path, 'debian/tests/three')
        self.assertEqual(ts[2].restrictions, {'needs-recommends'})
        self.assertEqual(ts[2].features, set())
        self.assertEqual(ts[2].depends, [])

        self.assertFalse(skipped)

    @patch('adtlog.report')
    def test_unknown_restriction(self, *args):
        '''unknown restriction'''

        (ts, skipped) = self.call_parse('Tests: t\nDepends:\nRestrictions: explodes-spontaneously')
        self.assertEqual(ts, [])
        self.assertTrue(skipped)
        adtlog.report.assert_called_once_with('t', 'SKIP unknown restriction explodes-spontaneously')

    @patch('adtlog.report')
    def test_unknown_field(self, *args):
        '''unknown field'''

        (ts, skipped) = self.call_parse('Tests: s\nFuture: quantum\n\nTests: t\nDepends:')
        self.assertEqual(len(ts), 1)
        self.assertEqual(ts[0].name, 't')
        self.assertTrue(skipped)
        adtlog.report.assert_called_once_with(
            's', 'SKIP unknown field Future')

    def test_invalid_control(self):
        '''invalid control file'''

        # no tests field
        with self.assertRaises(testdesc.InvalidControl) as cm:
            self.call_parse('Depends:')
        self.assertIn('missing "Tests"', str(cm.exception))

    def test_invalid_control_empty_test(self):
        '''another invalid control file'''

        # empty tests field
        with self.assertRaises(testdesc.InvalidControl) as cm:
            self.call_parse('Tests:')
        self.assertIn('"Tests" field is empty', str(cm.exception))

    def test_tests_dir(self):
        '''non-standard Tests-Directory'''

        (ts, skipped) = self.call_parse(
            'Tests: t1\nDepends:\nTests-Directory: src/checks\n\n'
            'Tests: t2 t3\nDepends:\nTests-Directory: lib/t')

        self.assertEqual(len(ts), 3)
        self.assertEqual(ts[0].path, 'src/checks/t1')
        self.assertEqual(ts[1].path, 'lib/t/t2')
        self.assertEqual(ts[2].path, 'lib/t/t3')
        self.assertFalse(skipped)

    def test_builddeps(self):
        '''@builddeps@ expansion'''

        (ts, skipped) = self.call_parse(
            'Tests: t\nDepends: @, @builddeps@, foo (>= 7)',
            'Source: nums\nBuild-Depends: bd1, bd2 [armhf], bd3:native (>= 7) | bd4 [linux-any]\n'
            'Build-Depends-Indep: bdi1, bdi2 [amd64]\n'
            'Build-Depends-Arch: bda1, bda2 [amd64]\n'
            '\n'
            'Package: one\nArchitecture: any')
        self.assertEqual(ts[0].depends, ['one', 'bd1', 'bd3:native (>= 7) | bd4',
                                         'bdi1', 'bdi2', 'bda1', 'bda2',
                                         'build-essential', 'foo (>= 7)'])
        self.assertFalse(skipped)

    @unittest.skipUnless(have_dpkg_build_profiles,
                         'dpkg version does not yet support build profiles')
    def test_builddeps_profiles(self):
        '''@builddeps@ expansion with build profiles'''

        (ts, skipped) = self.call_parse(
            'Tests: t\nDepends: @, @builddeps@',
            'Source: nums\nBuild-Depends: bd1, bd2 <!check>, bd3 <!cross>, bdnotme <stage1> <cross>\n'
            '\n'
            'Package: one\nArchitecture: any')
        self.assertEqual(ts[0].depends, ['one', 'bd1', 'bd2', 'bd3', 'build-essential'])
        self.assertFalse(skipped)

    def test_complex_deps(self):
        '''complex test dependencies'''

        (ts, skipped) = self.call_parse(
            'Tests: t\nDepends: @,\n foo (>= 7) [linux-any],\n'
            ' bd3:native (>= 4) | bd4 [armhf megacpu],\n',
            'Source: nums\n\nPackage: one\nArchitecture: any')
        self.assertEqual(ts[0].depends, ['one', 'foo (>= 7) [linux-any]',
                                         'bd3:native (>= 4) | bd4 [armhf megacpu]'])
        self.assertFalse(skipped)

    def test_deps_negative_arch(self):
        '''test dependencies with negative architecture'''

        (ts, skipped) = self.call_parse(
            'Tests: t\nDepends: foo-notc64 [!c64]\n',
            'Source: nums\n\nPackage: one\nArchitecture: any')
        self.assertEqual(ts[0].depends, ['foo-notc64 [!c64]'])
        self.assertFalse(skipped)

    def test_foreign_arch_test_dep(self):
        '''foreign architecture test dependencies'''

        (ts, skipped) = self.call_parse('Tests: t\nDepends: blah, foo:amd64, bar:i386 (>> 1)')
        self.assertEqual(ts[0].depends, ['blah', 'foo:amd64', 'bar:i386 (>> 1)'])
        self.assertFalse(skipped)

    def test_invalid_test_deps(self):
        '''invalid test dependencies'''

        with self.assertRaises(testdesc.InvalidControl) as cm:
            self.call_parse('Tests: t\nDepends: blah, foo:, bar (<> 1)')
        self.assertIn('Depends field contains an invalid dependency', str(cm.exception))
        self.assertIn('foo:', str(cm.exception))

    def test_comments(self):
        '''comments in control files with Unicode'''

        (ts, skipped) = self.call_parse(
            'Tests: t\n# ♪ ï\nDepends: @, @builddeps@',
            'Source: nums\nMaintainer: Üñïcøδ€ <u@x.com>\nBuild-Depends: bd1 # moo\n'
            '# more c☺ mments\n'
            '   # intented comment\n'
            ' , bd2\n'
            '\n'
            'Package: one\nArchitecture: any')
        self.assertEqual(ts[0].depends, ['one', 'bd1', 'bd2', 'build-essential'])
        self.assertFalse(skipped)

    @patch('adtlog.report')
    def test_testbed_unavail_root(self, *args):
        '''restriction needs-root incompatible with testbed'''

        (ts, skipped) = self.call_parse('Tests: t\nDepends:\nRestrictions: needs-root')
        self.assertEqual(ts, [])
        self.assertTrue(skipped)
        adtlog.report.assert_called_once_with(
            't', 'SKIP Test restriction "needs-root" requires testbed capability "root-on-testbed"')

    @patch('adtlog.report')
    def test_testbed_unavail_reboot(self, *args):
        '''restriction needs-reboot incompatible with testbed'''
        (ts, skipped) = self.call_parse('Tests: t\nDepends:\nRestrictions: needs-reboot')
        self.assertEqual(ts, [])
        self.assertTrue(skipped)
        adtlog.report.assert_called_once_with(
            't', 'SKIP Test restriction "needs-reboot" requires testbed capability "reboot"')

    @patch('adtlog.report')
    def test_testbed_unavail_container(self, *args):
        '''restriction isolation-container incompatible with testbed'''

        (ts, skipped) = self.call_parse('Tests: t\nDepends:\nRestrictions: isolation-container')
        self.assertEqual(ts, [])
        self.assertTrue(skipped)
        adtlog.report.assert_called_once_with(
            't', 'SKIP Test restriction "isolation-container" requires testbed capability "isolation-container" and/or "isolation-machine"')

    def test_custom_control_path(self):
        '''custom control file path'''

        os.makedirs(os.path.join(self.pkgdir, 'stuff'))
        c_path = os.path.join(self.pkgdir, 'stuff', 'ctrl')
        with open(c_path, 'w') as f:
            f.write('Tests: one\nDepends: foo')

        (ts, skipped) = testdesc.parse_debian_source(self.pkgdir, [], 'amd64',
                                                     control_path=c_path)
        self.assertEqual(len(ts), 1)
        t = ts[0]
        self.assertEqual(t.name, 'one')
        self.assertEqual(t.path, 'debian/tests/one')
        self.assertEqual(t.restrictions, set())
        self.assertEqual(t.features, set())
        self.assertEqual(t.depends, ['foo'])
        self.assertFalse(skipped)

    def test_test_command(self):
        '''single test, test command'''

        (ts, skipped) = self.call_parse('Test-Command: some -t --hing "foo"\nDepends:')
        self.assertEqual(len(ts), 1)
        t = ts[0]
        self.assertEqual(t.name, 'command1')
        self.assertEqual(t.path, None)
        self.assertEqual(t.command, 'some -t --hing "foo"')
        self.assertEqual(t.restrictions, set())
        self.assertEqual(t.features, set())
        self.assertEqual(t.depends, [])
        self.assertFalse(skipped)

    def test_test_command_and_tests(self):
        '''Both Tests: and Test-Command:'''

        with self.assertRaises(testdesc.InvalidControl) as cm:
            self.call_parse('Tests: t1\nTest-Command: true\nDepends:')
        self.assertIn('Tests', str(cm.exception))
        self.assertIn('Test-Command', str(cm.exception))
        self.assertIn(' or ', str(cm.exception))

    @patch('adtlog.report')
    def test_test_command_skip(self, *args):
        '''single test, skipped test command'''

        (ts, skipped) = self.call_parse('Test-Command: some --thing\nRestrictions: needs-root')
        self.assertEqual(ts, [])
        self.assertTrue(skipped)
        adtlog.report.assert_called_once_with(
            'command1', 'SKIP Test restriction "needs-root" requires testbed capability "root-on-testbed"')

    def test_classes(self):
        '''Classes: field'''

        (ts, skipped) = self.call_parse('Tests: one\nDepends:\nClasses: foo bar')
        self.assertEqual(len(ts), 1)
        t = ts[0]
        self.assertEqual(t.name, 'one')
        self.assertEqual(t.path, 'debian/tests/one')
        self.assertEqual(t.command, None)
        self.assertEqual(t.restrictions, set())
        self.assertEqual(t.features, set())
        self.assertEqual(t.depends, [])
        self.assertFalse(skipped)

    def test_comma_sep(self):
        '''comma separator in fields'''

        (ts, skipped) = self.call_parse(
            'Tests: t1, t2\nRestrictions: build-needed, allow-stderr\n'
            'Features: blue, green\n\n')
        self.assertEqual(len(ts), 2)

        self.assertEqual(ts[0].name, 't1')
        self.assertEqual(ts[1].name, 't2')
        for t in ts:
            self.assertEqual(t.restrictions, {'build-needed', 'allow-stderr'})
            self.assertEqual(t.features, {'blue', 'green'})

        self.assertFalse(skipped)

    def test_autodep8_ruby(self):
        '''autodep8 tests for Ruby packages'''

        with open(os.path.join(self.pkgdir, 'debian', 'ruby-tests.rb'), 'w') as f:
            f.write('exit(0)\n')
        (ts, skipped) = self.call_parse(None, 'Source: ruby-foo\n'
                                        'Build-Depends: gem2deb, rake\n\n'
                                        'Package: ruby-foo\nArchitecture: all')

        if have_autodep8:
            self.assertGreaterEqual(len(ts), 1)
            self.assertIn('gem2deb', ts[0].command)
        else:
            self.assertEqual(len(ts), 0)

    def test_autodep8_perl(self):
        '''autodep8 tests for Perl packages'''

        with open(os.path.join(self.pkgdir, 'Makefile.PL'), 'w') as f:
            f.write('use ExtUtils::MakeMaker;\n')
        os.makedirs(os.path.join(self.pkgdir, 't'))
        (ts, skipped) = self.call_parse(None, 'Source: libfoo-perl\n\n'
                                        'Package: libfoo-perl\nArchitecture: all')

        if have_autodep8:
            self.assertGreaterEqual(len(ts), 1)
            self.assertIn('pkg-perl-autopkgtest', ts[0].command)
            self.assertIn('pkg-perl-autopkgtest', ts[0].depends)
            self.assertIn('libfoo-perl', ts[0].depends)
        else:
            self.assertEqual(len(ts), 0)


if __name__ == '__main__':
    # Force encoding to UTF-8 even in non-UTF-8 locales.
    real_stdout = sys.stdout
    assert isinstance(real_stdout, io.TextIOBase)
    sys.stdout = io.TextIOWrapper(real_stdout.detach(), encoding="UTF-8", line_buffering=True)
    unittest.main(testRunner=unittest.TextTestRunner(stream=sys.stdout, verbosity=2))
