# kas - setup tool for bitbake based projects
#
# Copyright (c) Siemens AG, 2017-2018
#
# 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 os
import io
import textwrap
import contextlib

import pytest

from kas import includehandler, context
from kas.includehandler import ConfigFile


@pytest.fixture(autouse=True)
def fixed_version(monkeypatch):
    monkeypatch.setattr(includehandler, '__file_version__', 5)
    monkeypatch.setattr(includehandler, '__compatible_file_version__', 4)


@pytest.fixture(autouse=True)
def with_kas_context():
    context.create_global_context(None)
    yield
    context.__context__ = None


class MockFileIO(io.StringIO):
    def close(self):
        self.seek(0)


def mock_file(indented_content):
    return MockFileIO(textwrap.dedent(indented_content))


@contextlib.contextmanager
def patch_open(component, string='', dictionary=None):
    dictionary = dictionary or {}
    old_attr = getattr(component, 'open', None)
    component.open = lambda f, *a, **k: mock_file(dictionary.get(f, string))
    yield
    if old_attr:
        component.open = old_attr
    else:
        del component.open


class TestLoadConfig:
    def test_err_invalid_ext(self):
        # Test for invalid file extension:
        exception = includehandler.LoadConfigException
        with pytest.raises(exception):
            ConfigFile.load('x.xyz')

    def util_exception_content(self, testvector):
        for string, exception in testvector:
            with patch_open(includehandler, string=string):
                with pytest.raises(exception):
                    ConfigFile.load('x.yml')

    def test_err_header_missing(self):
        exception = includehandler.LoadConfigException
        testvector = [
            ('', exception),
            ('a', exception),
            ('1', exception),
            ('a:', exception)
        ]

        self.util_exception_content(testvector)

    def test_err_header_invalid_type(self):
        exception = includehandler.LoadConfigException
        testvector = [
            ('header:', exception),
            ('header: 1', exception),
            ('header: a', exception),
            ('header: []', exception),
        ]

        self.util_exception_content(testvector)

    def test_err_version_missing(self):
        exception = includehandler.LoadConfigException
        testvector = [
            ('header: {}', exception),
            ('header: {a: 1}', exception),
        ]

        self.util_exception_content(testvector)

    def test_err_version_invalid_format(self):
        exception = includehandler.LoadConfigException
        testvector = [
            ('header: {version: "0.5"}', exception),
            ('header: {version: "x"}', exception),
            ('header: {version: 3}', exception),
            ('header: {version: 6}', exception),
        ]

        self.util_exception_content(testvector)

    def test_err_parse_yaml(self):
        exception = includehandler.LoadConfigException
        testvector = [
            # misaligned column
            ('header:\n  version: 17\n repo:', exception),
        ]
        self.util_exception_content(testvector)

    def test_header_valid(self):
        testvector = [
            'header: {version: 4}',
            'header: {version: 5}',
        ]
        for string in testvector:
            with patch_open(includehandler, string=string):
                ConfigFile.load('x.yml')

    def test_compat_version(self, monkeypatch):
        monkeypatch.setattr(includehandler, '__compatible_file_version__', 1)
        with patch_open(includehandler, string='header: {version: "0.10"}'):
            ConfigFile.load('x.yml')

    def test_source_dir_rejected_in_non_main_file(self):
        string = 'header: {version: 5}\n_source_dir: /some/path'
        with patch_open(includehandler, string=string):
            with pytest.raises(includehandler.LoadConfigException):
                ConfigFile.load('x.yml', is_main_file=False)

    def test_source_dir_allowed_in_main_file(self):
        string = 'header: {version: 5}\n_source_dir: /some/path'
        with patch_open(includehandler, string=string):
            cf = ConfigFile.load('x.yml', is_main_file=True)
            assert cf.src_dir == '/some/path'

    def test_signers_require_path_or_keyserver(self):
        exception = includehandler.LoadConfigException
        testvector = [
            # signer with neither path nor gpg_keyserver
            ('header: {version: 5}\n'
             'signers:\n'
             '  mysigner:\n'
             '    type: gpg\n', exception),
        ]
        self.util_exception_content(testvector)

    def test_signers_path_requires_repo(self):
        exception = includehandler.LoadConfigException
        testvector = [
            # path without repo
            ('header: {version: 5}\n'
             'signers:\n'
             '  mysigner:\n'
             '    path: key.asc\n', exception),
        ]
        self.util_exception_content(testvector)

    def test_signers_keyserver_requires_fingerprint(self):
        exception = includehandler.LoadConfigException
        testvector = [
            # gpg_keyserver without fingerprint
            ('header: {version: 5}\n'
             'signers:\n'
             '  mysigner:\n'
             '    gpg_keyserver: keyserver.ubuntu.com\n', exception),
        ]
        self.util_exception_content(testvector)

    def test_signers_ssh_requires_path_and_repo(self):
        exception = includehandler.LoadConfigException
        testvector = [
            # ssh type with keyserver but no path
            ('header: {version: 5}\n'
             'signers:\n'
             '  mysigner:\n'
             '    type: ssh\n'
             '    gpg_keyserver: keyserver.ubuntu.com\n'
             '    fingerprint: 2AFB13F28FBBB0D1B9DAF63087EB3D32FB631AD9\n',
             exception),
            # ssh type with path but no repo
            ('header: {version: 5}\n'
             'signers:\n'
             '  mysigner:\n'
             '    type: ssh\n'
             '    path: key.pub\n',
             exception),
        ]
        self.util_exception_content(testvector)

    def test_signers_valid(self):
        testvector = [
            # path with repo
            ('header: {version: 5}\n'
             'signers:\n'
             '  mysigner:\n'
             '    repo: this\n'
             '    path: key.asc\n'),
            # keyserver with fingerprint
            ('header: {version: 5}\n'
             'signers:\n'
             '  mysigner:\n'
             '    gpg_keyserver: keyserver.ubuntu.com\n'
             '    fingerprint: 2AFB13F28FBBB0D1B9DAF63087EB3D32FB631AD9\n'),
            # ssh type with path and repo
            ('header: {version: 5}\n'
             'signers:\n'
             '  mysigner:\n'
             '    type: ssh\n'
             '    repo: this\n'
             '    path: key.pub\n'),
        ]
        for string in testvector:
            with patch_open(includehandler, string=string):
                ConfigFile.load('x.yml')


class TestIncludes:
    header = '''
header:
  version: 5
{}'''

    def util_include_content(self, testvector, monkeypatch):
        # disable schema validation for these tests:
        monkeypatch.setattr(includehandler, 'CONFIGSCHEMA', {})
        for test in testvector:
            with patch_open(includehandler, dictionary=test['fdict']):
                ginc = includehandler.IncludeHandler(['x.yml'])
                config, missing = ginc.get_config(repos=test['rdict'])

                # Remove header, because we dont want to compare it:
                config.pop('header')

                assert test['conf'] == config
                assert test['rmiss'] == missing

                if 'cfiles' in test:
                    assert len(ginc.config_files) == len(test['cfiles'])

                    for i, cf in enumerate(ginc.config_files):
                        assert test['cfiles'][i][0] == str(cf.filename)
                        assert test['cfiles'][i][1] == cf.is_lockfile
                        assert test['cfiles'][i][2] == cf.is_external

    def test_valid_includes_none(self, monkeypatch):
        header = self.__class__.header
        testvector = [
            {
                'fdict': {
                    'x.yml': header.format('')
                },
                'rdict': {
                },
                'conf': {
                },
                'rmiss': [
                ],
                'cfiles': [
                    (
                        'x.yml',
                        False,
                        False
                    )
                ]
            },
        ]

        self.util_include_content(testvector, monkeypatch)

    def test_valid_includes_some(self, monkeypatch):
        header = self.__class__.header
        testvector = [
            # Include one file from the same repo:
            {
                'fdict': {
                    'x.yml': header.format('  includes: ["y.yml"]'),
                    os.path.abspath('y.yml'): header.format('\nv:')
                },
                'rdict': {
                },
                'conf': {
                    'v': None
                },
                'rmiss': [
                ],
                'cfiles': [
                    (
                        os.path.abspath('y.yml'),
                        False,
                        False
                    ),
                    (
                        'x.yml',
                        False,
                        False
                    )
                ]
            },
            # Include one file from another not available repo:
            {
                'fdict': {
                    'x.yml': header.format(
                        '  includes: [{repo: rep, file: y.yml}]'),
                },
                'rdict': {
                },
                'conf': {
                },
                'rmiss': [
                    'rep',
                ],
                'cfiles': [
                    (
                        'x.yml',
                        False,
                        False
                    )
                ]
            },
            # Include one file from the same repo and one from another
            # not available repo:
            {
                'fdict': {
                    'x.yml': header.format('  includes: ["y.yml", '
                                           '{repo: rep, file: y.yml}]'),
                    os.path.abspath('y.yml'): header.format('\nv:')
                },
                'rdict': {
                },
                'conf': {
                    'v': None
                },
                'rmiss': [
                    'rep',
                ],
                'cfiles': [
                    (
                        os.path.abspath('y.yml'),
                        False,
                        False
                    ),
                    (
                        'x.yml',
                        False,
                        False
                    )
                ]
            },
            # Include one file from another available repo:
            {
                'fdict': {
                    'x.yml': header.format(
                        '  includes: [{repo: rep, file: y.yml}]'),
                    '/rep/y.yml': header.format('\nv:')
                },
                'rdict': {
                    'rep': '/rep'
                },
                'conf': {
                    'v': None
                },
                'rmiss': [
                ],
                'cfiles': [
                    (
                        '/rep/y.yml',
                        False,
                        True
                    ),
                    (
                        'x.yml',
                        False,
                        False
                    )
                ]
            },
            # Include two files from another repo in sub-directories:
            {
                'fdict': {
                    'x.yml': header.format(
                        '  includes: [{repo: rep, file: dir1/y.yml}]'),
                    '/rep/dir1/y.yml': header.format(
                        '  includes: ["dir2/z.yml"]'),
                    '/rep/dir2/z.yml': header.format('\nv:')
                },
                'rdict': {
                    'rep': '/rep'
                },
                'conf': {
                    'v': None
                },
                'rmiss': [
                ],
                'cfiles': [
                    (
                        '/rep/dir2/z.yml',
                        False,
                        True
                    ),
                    (
                        '/rep/dir1/y.yml',
                        False,
                        True
                    ),
                    (
                        'x.yml',
                        False,
                        False
                    )
                ]
            },
        ]

        self.util_include_content(testvector, monkeypatch)

    def test_valid_overwriting(self, monkeypatch):
        header = self.__class__.header
        testvector = [
            {
                'fdict': {
                    'x.yml': header.format('''  includes: ["y.yml"]
v: x'''),
                    os.path.abspath('y.yml'): header.format('''
v: y''')
                },
                'rdict': {
                },
                'conf': {
                    'v': 'x'
                },
                'rmiss': [
                ]
            },
            {
                'fdict': {
                    'x.yml': header.format('''  includes: ["y.yml"]
v: {v: x}'''),
                    os.path.abspath('y.yml'): header.format('''
v: {v: y}''')
                },
                'rdict': {
                },
                'conf': {
                    'v': {'v': 'x'}
                },
                'rmiss': [
                ]
            },
            {
                'fdict': {
                    'x.yml': header.format('''  includes: ["y.yml"]
v1:
v2: []
v3:
  - a: c'''),
                    os.path.abspath('y.yml'): header.format('''
v1: a
v2: [a]
v3:
  - a: b
  - d: c}]''')
                },
                'rdict': {
                },
                'conf': {
                    'v1': None,
                    'v2': [],
                    'v3': [{'a': 'c'}]
                },
                'rmiss': [
                ]
            },
        ]

        self.util_include_content(testvector, monkeypatch)

    def test_valid_merging(self, monkeypatch):
        header = self.__class__.header
        testvector = [
            {
                'fdict': {
                    'x.yml': header.format('''  includes: ["y.yml"]
v1: x
v3:
  a: b
  b:
    e:
  c: d'''),
                    os.path.abspath('y.yml'): header.format('''
v2: y
v3:
  d: e
  b:
    c:
  e: f''')
                },
                'rdict': {
                },
                'conf': {
                    'v1': 'x',
                    'v2': 'y',
                    'v3': {
                        'a': 'b',
                        'b': {'c': None, 'e': None},
                        'c': 'd',
                        'd': 'e',
                        'e': 'f'}
                },
                'rmiss': [
                ]
            },
        ]

        self.util_include_content(testvector, monkeypatch)

    def test_valid_ordering(self, monkeypatch):
        # disable schema validation for this test:
        monkeypatch.setattr(includehandler, 'CONFIGSCHEMA', {})
        header = self.__class__.header
        data = {'x.yml':
                header.format('''  includes: ["y.yml", "z.yml"]
v: {v1: x, v2: x}'''),
                os.path.abspath('y.yml'):
                header.format('''  includes: ["z.yml"]
v: {v2: y, v3: y, v5: y}'''),
                os.path.abspath('z.yml'): header.format('''
v: {v3: z, v4: z}''')}
        with patch_open(includehandler, dictionary=data):
            ginc = includehandler.IncludeHandler(['x.yml'])
            config, _ = ginc.get_config()
            keys = list(config['v'].keys())
            index = {keys[i]: i for i in range(len(keys))}

            # Check for vars in z.yml:
            assert index['v3'] < index['v1']
            assert index['v3'] < index['v2']
            assert index['v3'] < index['v5']
            assert index['v4'] < index['v1']
            assert index['v4'] < index['v2']
            assert index['v4'] < index['v5']

            # Check for vars in y.yml:
            assert index['v2'] < index['v1']
            assert index['v3'] < index['v1']
            assert index['v5'] < index['v1']

    def test_err_string_include_traversal(self, monkeypatch):
        monkeypatch.setattr(includehandler, 'CONFIGSCHEMA', {})
        header = self.__class__.header
        fdict = {
            'x.yml': header.format(
                '  includes: [{repo: rep, file: y.yml}]'),
            '/top-repo/repos/rep/y.yml': header.format(
                '  includes: ["../../other-repo/secret.yml"]'),
        }
        with patch_open(includehandler, dictionary=fdict):
            ginc = includehandler.IncludeHandler(['x.yml'])
            with pytest.raises(includehandler.IncludeException):
                ginc.get_config(repos={'rep': '/top-repo/repos/rep'})

    def test_err_repo_include_traversal(self, monkeypatch):
        monkeypatch.setattr(includehandler, 'CONFIGSCHEMA', {})
        header = self.__class__.header
        fdict = {
            'x.yml': header.format(
                '  includes: [{repo: rep, '
                'file: "../../other-repo/secret.yml"}]'),
        }
        with patch_open(includehandler, dictionary=fdict):
            ginc = includehandler.IncludeHandler(['x.yml'])
            with pytest.raises(includehandler.IncludeException):
                ginc.get_config(repos={'rep': '/top-repo/repos/rep'})
