# SPDX-License-Identifier: Apache-2.0
# Copyright 2016-2021 The Meson development team

from __future__ import annotations

import subprocess
import tempfile
import textwrap
import os
import shutil
import functools
import threading
import sys
from itertools import chain
from unittest import mock, skipIf, SkipTest
from pathlib import Path
import typing as T

import mesonbuild.mlog
import mesonbuild.depfile
import mesonbuild.dependencies.factory
import mesonbuild.envconfig
import mesonbuild.environment
import mesonbuild.coredata
import mesonbuild.modules.gnome
from mesonbuild.mesonlib import (
    MachineChoice, is_windows, is_osx, is_cygwin, is_haiku, is_sunos
)
from mesonbuild.compilers import (
    detect_swift_compiler, compiler_from_language
)
import mesonbuild.modules.pkgconfig


from run_tests import (
    Backend,
    get_fake_env
)

from .baseplatformtests import BasePlatformTests
from .helpers import *

@functools.lru_cache()
def is_real_gnu_compiler(path):
    '''
    Check if the gcc we have is a real gcc and not a macOS wrapper around clang
    '''
    if not path:
        return False
    out = subprocess.check_output([path, '--version'], universal_newlines=True, stderr=subprocess.STDOUT)
    return 'Free Software Foundation' in out

class NativeFileTests(BasePlatformTests):

    def setUp(self):
        super().setUp()
        self.testcase = os.path.join(self.unit_test_dir, '46 native file binary')
        self.current_config = 0
        self.current_wrapper = 0

    def helper_create_native_file(self, values: T.Dict[str, T.Dict[str, T.Union[str, int, float, bool, T.Sequence[T.Union[str, int, float, bool]]]]]) -> str:
        """Create a config file as a temporary file.

        values should be a nested dictionary structure of {section: {key:
        value}}
        """
        filename = os.path.join(self.builddir, f'generated{self.current_config}.config')
        self.current_config += 1
        with open(filename, 'wt', encoding='utf-8') as f:
            for section, entries in values.items():
                f.write(f'[{section}]\n')
                for k, v in entries.items():
                    if isinstance(v, (bool, int, float)):
                        f.write(f"{k}={v}\n")
                    elif isinstance(v, str):
                        f.write(f"{k}='{v}'\n")
                    else:
                        f.write("{}=[{}]\n".format(k, ', '.join([f"'{w}'" for w in v])))
        return filename

    def helper_create_binary_wrapper(self, binary, dir_=None, extra_args=None, **kwargs):
        """Creates a wrapper around a binary that overrides specific values."""
        filename = os.path.join(dir_ or self.builddir, f'binary_wrapper{self.current_wrapper}.py')
        extra_args = extra_args or {}
        self.current_wrapper += 1
        if is_haiku():
            chbang = '#!/bin/env python3'
        else:
            chbang = '#!/usr/bin/env python3'

        with open(filename, 'wt', encoding='utf-8') as f:
            f.write(textwrap.dedent('''\
                {}
                import argparse
                import subprocess
                import sys

                def main():
                    parser = argparse.ArgumentParser()
                '''.format(chbang)))
            for name in chain(extra_args, kwargs):
                f.write('    parser.add_argument("-{0}", "--{0}", action="store_true")\n'.format(name))
            f.write('    args, extra_args = parser.parse_known_args()\n')
            for name, value in chain(extra_args.items(), kwargs.items()):
                f.write(f'    if args.{name}:\n')
                f.write('        print("{}", file=sys.{})\n'.format(value, kwargs.get('outfile', 'stdout')))
                f.write('        sys.exit(0)\n')
            f.write(textwrap.dedent('''
                    ret = subprocess.run(
                        ["{}"] + extra_args,
                        stdout=subprocess.PIPE,
                        stderr=subprocess.PIPE)
                    print(ret.stdout.decode('utf-8'))
                    print(ret.stderr.decode('utf-8'), file=sys.stderr)
                    sys.exit(ret.returncode)

                if __name__ == '__main__':
                    main()
                '''.format(binary)))

        if not is_windows():
            os.chmod(filename, 0o755)
            return filename

        # On windows we need yet another level of indirection, as cmd cannot
        # invoke python files itself, so instead we generate a .bat file, which
        # invokes our python wrapper
        batfile = os.path.join(self.builddir, f'binary_wrapper{self.current_wrapper}.bat')
        with open(batfile, 'wt', encoding='utf-8') as f:
            f.write(fr'@{sys.executable} {filename} %*')
        return batfile

    def helper_for_compiler(self, lang, cb, for_machine = MachineChoice.HOST):
        """Helper for generating tests for overriding compilers for languages
        with more than one implementation, such as C, C++, ObjC, ObjC++, and D.
        """
        env = get_fake_env()
        getter = lambda: compiler_from_language(env, lang, for_machine)
        cc = getter()
        binary, newid = cb(cc)
        env.binaries[for_machine].binaries[lang] = binary
        compiler = getter()
        self.assertEqual(compiler.id, newid)

    def test_multiple_native_files_override(self):
        wrapper = self.helper_create_binary_wrapper('bash', version='foo')
        config = self.helper_create_native_file({'binaries': {'bash': wrapper}})
        wrapper = self.helper_create_binary_wrapper('bash', version='12345')
        config2 = self.helper_create_native_file({'binaries': {'bash': wrapper}})
        self.init(self.testcase, extra_args=[
            '--native-file', config, '--native-file', config2,
            '-Dcase=find_program'])

    # This test hangs on cygwin.
    @skipIf(os.name != 'posix' or is_cygwin(), 'Uses fifos, which are not available on non Unix OSes.')
    def test_native_file_is_pipe(self):
        fifo = os.path.join(self.builddir, 'native.file')
        os.mkfifo(fifo)
        with tempfile.TemporaryDirectory() as d:
            wrapper = self.helper_create_binary_wrapper('bash', d, version='12345')

            def filler():
                with open(fifo, 'w', encoding='utf-8') as f:
                    f.write('[binaries]\n')
                    f.write(f"bash = '{wrapper}'\n")

            thread = threading.Thread(target=filler)
            thread.start()

            self.init(self.testcase, extra_args=['--native-file', fifo, '-Dcase=find_program'])

            thread.join()
            os.unlink(fifo)

            self.init(self.testcase, extra_args=['--wipe'])

    def test_multiple_native_files(self):
        wrapper = self.helper_create_binary_wrapper('bash', version='12345')
        config = self.helper_create_native_file({'binaries': {'bash': wrapper}})
        wrapper = self.helper_create_binary_wrapper('python')
        config2 = self.helper_create_native_file({'binaries': {'python': wrapper}})
        self.init(self.testcase, extra_args=[
            '--native-file', config, '--native-file', config2,
            '-Dcase=find_program'])

    def _simple_test(self, case, binary, entry=None):
        wrapper = self.helper_create_binary_wrapper(binary, version='12345')
        config = self.helper_create_native_file({'binaries': {entry or binary: wrapper}})
        self.init(self.testcase, extra_args=['--native-file', config, f'-Dcase={case}'])

    def test_find_program(self):
        self._simple_test('find_program', 'bash')

    def test_config_tool_dep(self):
        # Do the skip at this level to avoid screwing up the cache
        if mesonbuild.environment.detect_msys2_arch():
            raise SkipTest('Skipped due to problems with LLVM on MSYS2')
        if not shutil.which('llvm-config'):
            raise SkipTest('No llvm-installed, cannot test')
        self._simple_test('config_dep', 'llvm-config')

    def test_python3_module(self):
        self._simple_test('python3', 'python3')

    def test_python_module(self):
        if is_windows():
            # Bat adds extra crap to stdout, so the version check logic in the
            # python module breaks. This is fine on other OSes because they
            # don't need the extra indirection.
            raise SkipTest('bat indirection breaks internal sanity checks.')
        elif is_osx():
            binary = 'python'
        else:
            binary = 'python2'

            # We not have python2, check for it
            for v in ['2', '2.7', '-2.7']:
                try:
                    rc = subprocess.call(['pkg-config', '--cflags', f'python{v}'],
                                         stdout=subprocess.DEVNULL,
                                         stderr=subprocess.DEVNULL)
                except FileNotFoundError:
                    raise SkipTest('Not running Python 2 tests because pkg-config not found.')
                if rc == 0:
                    break
            else:
                raise SkipTest('Not running Python 2 tests because dev packages not installed.')
        self._simple_test('python', binary, entry='python')

    @skipIf(is_windows(), 'Setting up multiple compilers on windows is hard')
    @skip_if_env_set('CC')
    def test_c_compiler(self):
        def cb(comp):
            if comp.id == 'gcc':
                if not shutil.which('clang'):
                    raise SkipTest('Only one compiler found, cannot test.')
                return 'clang', 'clang'
            if not is_real_gnu_compiler(shutil.which('gcc')):
                raise SkipTest('Only one compiler found, cannot test.')
            return 'gcc', 'gcc'
        self.helper_for_compiler('c', cb)

    @skipIf(is_windows(), 'Setting up multiple compilers on windows is hard')
    @skip_if_env_set('CXX')
    def test_cpp_compiler(self):
        def cb(comp):
            if comp.id == 'gcc':
                if not shutil.which('clang++'):
                    raise SkipTest('Only one compiler found, cannot test.')
                return 'clang++', 'clang'
            if not is_real_gnu_compiler(shutil.which('g++')):
                raise SkipTest('Only one compiler found, cannot test.')
            return 'g++', 'gcc'
        self.helper_for_compiler('cpp', cb)

    @skip_if_not_language('objc')
    @skip_if_env_set('OBJC')
    def test_objc_compiler(self):
        def cb(comp):
            if comp.id == 'gcc':
                if not shutil.which('clang'):
                    raise SkipTest('Only one compiler found, cannot test.')
                return 'clang', 'clang'
            if not is_real_gnu_compiler(shutil.which('gcc')):
                raise SkipTest('Only one compiler found, cannot test.')
            return 'gcc', 'gcc'
        self.helper_for_compiler('objc', cb)

    @skip_if_not_language('objcpp')
    @skip_if_env_set('OBJCXX')
    def test_objcpp_compiler(self):
        def cb(comp):
            if comp.id == 'gcc':
                if not shutil.which('clang++'):
                    raise SkipTest('Only one compiler found, cannot test.')
                return 'clang++', 'clang'
            if not is_real_gnu_compiler(shutil.which('g++')):
                raise SkipTest('Only one compiler found, cannot test.')
            return 'g++', 'gcc'
        self.helper_for_compiler('objcpp', cb)

    @skip_if_not_language('d')
    @skip_if_env_set('DC')
    def test_d_compiler(self):
        def cb(comp):
            if comp.id == 'dmd':
                if shutil.which('ldc'):
                    return 'ldc', 'ldc'
                elif shutil.which('gdc'):
                    return 'gdc', 'gdc'
                else:
                    raise SkipTest('No alternative dlang compiler found.')
            if shutil.which('dmd'):
                return 'dmd', 'dmd'
            raise SkipTest('No alternative dlang compiler found.')
        self.helper_for_compiler('d', cb)

    @skip_if_not_language('cs')
    @skip_if_env_set('CSC')
    def test_cs_compiler(self):
        def cb(comp):
            if comp.id == 'csc':
                if not shutil.which('mcs'):
                    raise SkipTest('No alternate C# implementation.')
                return 'mcs', 'mcs'
            if not shutil.which('csc'):
                raise SkipTest('No alternate C# implementation.')
            return 'csc', 'csc'
        self.helper_for_compiler('cs', cb)

    @skip_if_not_language('fortran')
    @skip_if_env_set('FC')
    def test_fortran_compiler(self):
        def cb(comp):
            if comp.id == 'lcc':
                if shutil.which('lfortran'):
                    return 'lfortran', 'lcc'
                raise SkipTest('No alternate Fortran implementation.')
            elif comp.id == 'gcc':
                if shutil.which('ifort'):
                    # There is an ICC for windows (windows build, linux host),
                    # but we don't support that ATM so lets not worry about it.
                    if is_windows():
                        return 'ifort', 'intel-cl'
                    return 'ifort', 'intel'
                elif shutil.which('flang'):
                    return 'flang', 'flang'
                elif shutil.which('pgfortran'):
                    return 'pgfortran', 'pgi'
                # XXX: there are several other fortran compilers meson
                # supports, but I don't have any of them to test with
                raise SkipTest('No alternate Fortran implementation.')
            if not shutil.which('gfortran'):
                raise SkipTest('No alternate Fortran implementation.')
            return 'gfortran', 'gcc'
        self.helper_for_compiler('fortran', cb)

    def _single_implementation_compiler(self, lang: str, binary: str, version_str: str, version: str) -> None:
        """Helper for languages with a single (supported) implementation.

        Builds a wrapper around the compiler to override the version.
        """
        wrapper = self.helper_create_binary_wrapper(binary, version=version_str)
        env = get_fake_env()
        env.binaries.host.binaries[lang] = [wrapper]
        compiler = compiler_from_language(env, lang, MachineChoice.HOST)
        self.assertEqual(compiler.version, version)

    @skip_if_not_language('vala')
    @skip_if_env_set('VALAC')
    def test_vala_compiler(self):
        self._single_implementation_compiler(
            'vala', 'valac', 'Vala 1.2345', '1.2345')

    @skip_if_not_language('rust')
    @skip_if_env_set('RUSTC')
    def test_rust_compiler(self):
        self._single_implementation_compiler(
            'rust', 'rustc', 'rustc 1.2345', '1.2345')

    @skip_if_not_language('java')
    def test_java_compiler(self):
        self._single_implementation_compiler(
            'java', 'javac', 'javac 9.99.77', '9.99.77')

    @skip_if_not_language('java')
    def test_java_classpath(self):
        if self.backend is not Backend.ninja:
            raise SkipTest('Jar is only supported with Ninja')
        testdir = os.path.join(self.unit_test_dir, '112 classpath')
        self.init(testdir)
        self.build()
        one_build_path = get_classpath(os.path.join(self.builddir, 'one.jar'))
        self.assertIsNone(one_build_path)
        two_build_path = get_classpath(os.path.join(self.builddir, 'two.jar'))
        self.assertEqual(two_build_path, 'one.jar')
        self.install()
        one_install_path = get_classpath(os.path.join(self.installdir, 'usr/bin/one.jar'))
        self.assertIsNone(one_install_path)
        two_install_path = get_classpath(os.path.join(self.installdir, 'usr/bin/two.jar'))
        self.assertIsNone(two_install_path)

    @skip_if_not_language('swift')
    def test_swift_compiler(self):
        wrapper = self.helper_create_binary_wrapper(
            'swiftc', version='Swift 1.2345', outfile='stderr',
            extra_args={'Xlinker': 'macosx_version. PROJECT:ld - 1.2.3'})
        env = get_fake_env()
        env.binaries.host.binaries['swift'] = [wrapper]
        compiler = detect_swift_compiler(env, MachineChoice.HOST)
        self.assertEqual(compiler.version, '1.2345')

    def test_native_file_dirs(self):
        testcase = os.path.join(self.unit_test_dir, '59 native file override')
        self.init(testcase, default_args=False,
                  extra_args=['--native-file', os.path.join(testcase, 'nativefile')])

    def test_native_file_dirs_overridden(self):
        testcase = os.path.join(self.unit_test_dir, '59 native file override')
        self.init(testcase, default_args=False,
                  extra_args=['--native-file', os.path.join(testcase, 'nativefile'),
                              '-Ddef_libdir=liblib', '-Dlibdir=liblib'])

    def test_compile_sys_path(self):
        """Compiling with a native file stored in a system path works.

        There was a bug which caused the paths to be stored incorrectly and
        would result in ninja invoking meson in an infinite loop. This tests
        for that by actually invoking ninja.
        """
        testcase = os.path.join(self.common_test_dir, '1 trivial')

        # It really doesn't matter what's in the native file, just that it exists
        config = self.helper_create_native_file({'binaries': {'bash': 'false'}})

        self.init(testcase, extra_args=['--native-file', config])
        self.build()

    def test_user_options(self):
        testcase = os.path.join(self.common_test_dir, '40 options')
        for opt, value in [('testoption', 'some other val'), ('other_one', True),
                           ('combo_opt', 'one'), ('array_opt', ['two']),
                           ('integer_opt', 0),
                           ('CaseSenSiTivE', 'SOME other Value'),
                           ('CASESENSITIVE', 'some other Value')]:
            config = self.helper_create_native_file({'project options': {opt: value}})
            with self.assertRaises(subprocess.CalledProcessError) as cm:
                self.init(testcase, extra_args=['--native-file', config])
                self.assertRegex(cm.exception.stdout, r'Incorrect value to [a-z]+ option')

    def test_user_options_command_line_overrides(self):
        testcase = os.path.join(self.common_test_dir, '40 options')
        config = self.helper_create_native_file({'project options': {'other_one': True}})
        self.init(testcase, extra_args=['--native-file', config, '-Dother_one=false'])

    def test_user_options_subproject(self):
        testcase = os.path.join(self.unit_test_dir, '78 user options for subproject')

        s = os.path.join(testcase, 'subprojects')
        if not os.path.exists(s):
            os.mkdir(s)
        s = os.path.join(s, 'sub')
        if not os.path.exists(s):
            sub = os.path.join(self.common_test_dir, '40 options')
            shutil.copytree(sub, s)

        for opt, value in [('testoption', 'some other val'), ('other_one', True),
                           ('combo_opt', 'one'), ('array_opt', ['two']),
                           ('integer_opt', 0)]:
            config = self.helper_create_native_file({'sub:project options': {opt: value}})
            with self.assertRaises(subprocess.CalledProcessError) as cm:
                self.init(testcase, extra_args=['--native-file', config])
                self.assertRegex(cm.exception.stdout, r'Incorrect value to [a-z]+ option')

    def test_option_bool(self):
        # Bools are allowed to be unquoted
        testcase = os.path.join(self.common_test_dir, '1 trivial')
        config = self.helper_create_native_file({'built-in options': {'werror': True}})
        self.init(testcase, extra_args=['--native-file', config])
        configuration = self.introspect('--buildoptions')
        for each in configuration:
            # Test that no-per subproject options are inherited from the parent
            if 'werror' in each['name']:
                self.assertEqual(each['value'], True)
                break
        else:
            self.fail('Did not find werror in build options?')

    def test_option_integer(self):
        # Bools are allowed to be unquoted
        testcase = os.path.join(self.common_test_dir, '1 trivial')
        config = self.helper_create_native_file({'built-in options': {'unity_size': 100}})
        self.init(testcase, extra_args=['--native-file', config])
        configuration = self.introspect('--buildoptions')
        for each in configuration:
            # Test that no-per subproject options are inherited from the parent
            if 'unity_size' in each['name']:
                self.assertEqual(each['value'], 100)
                break
        else:
            self.fail('Did not find unity_size in build options?')

    def test_builtin_options(self):
        testcase = os.path.join(self.common_test_dir, '2 cpp')
        config = self.helper_create_native_file({'built-in options': {'cpp_std': 'c++14'}})

        self.init(testcase, extra_args=['--native-file', config])
        configuration = self.introspect('--buildoptions')
        for each in configuration:
            if each['name'] == 'cpp_std':
                self.assertEqual(each['value'], 'c++14')
                break
        else:
            self.fail('Did not find werror in build options?')

    def test_builtin_options_conf_overrides_env(self):
        testcase = os.path.join(self.common_test_dir, '2 cpp')
        config = self.helper_create_native_file({'built-in options': {'pkg_config_path': '/foo'}})

        self.init(testcase, extra_args=['--native-file', config], override_envvars={'PKG_CONFIG_PATH': '/bar'})
        configuration = self.introspect('--buildoptions')
        for each in configuration:
            if each['name'] == 'pkg_config_path':
                self.assertEqual(each['value'], ['/foo'])
                break
        else:
            self.fail('Did not find pkg_config_path in build options?')

    def test_builtin_options_subprojects(self):
        testcase = os.path.join(self.common_test_dir, '98 subproject subdir')
        config = self.helper_create_native_file({'built-in options': {'default_library': 'both', 'c_args': ['-Dfoo']}, 'sub:built-in options': {'default_library': 'static'}})

        self.init(testcase, extra_args=['--native-file', config])
        configuration = self.introspect('--buildoptions')
        found = 0
        for each in configuration:
            # Test that no-per subproject options are inherited from the parent
            if 'c_args' in each['name']:
                # This path will be hit twice, once for build and once for host,
                self.assertEqual(each['value'], ['-Dfoo'])
                found += 1
            elif each['name'] == 'default_library':
                self.assertEqual(each['value'], 'both')
                found += 1
            elif each['name'] == 'sub:default_library':
                self.assertEqual(each['value'], 'static')
                found += 1
        self.assertEqual(found, 4, 'Did not find all three sections')

    def test_builtin_options_subprojects_overrides_buildfiles(self):
        # If the buildfile says subproject(... default_library: shared), ensure that's overwritten
        testcase = os.path.join(self.common_test_dir, '223 persubproject options')
        config = self.helper_create_native_file({'sub2:built-in options': {'default_library': 'shared'}})

        with self.assertRaises((RuntimeError, subprocess.CalledProcessError)) as cm:
            self.init(testcase, extra_args=['--native-file', config])
            if isinstance(cm, RuntimeError):
                check = str(cm.exception)
            else:
                check = cm.exception.stdout
            self.assertIn(check, 'Parent should override default_library')

    def test_builtin_options_subprojects_dont_inherits_parent_override(self):
        # If the buildfile says subproject(... default_library: shared), ensure that's overwritten
        testcase = os.path.join(self.common_test_dir, '223 persubproject options')
        config = self.helper_create_native_file({'built-in options': {'default_library': 'both'}})
        self.init(testcase, extra_args=['--native-file', config])

    def test_builtin_options_compiler_properties(self):
        # the properties section can have lang_args, and those need to be
        # overwritten by the built-in options
        testcase = os.path.join(self.common_test_dir, '1 trivial')
        config = self.helper_create_native_file({
            'built-in options': {'c_args': ['-DFOO']},
            'properties': {'c_args': ['-DBAR']},
        })

        self.init(testcase, extra_args=['--native-file', config])
        configuration = self.introspect('--buildoptions')
        for each in configuration:
            if each['name'] == 'c_args':
                self.assertEqual(each['value'], ['-DFOO'])
                break
        else:
            self.fail('Did not find c_args in build options?')

    def test_builtin_options_compiler_properties_legacy(self):
        # The legacy placement in properties is still valid if a 'built-in
        # options' setting is present, but doesn't have the lang_args
        testcase = os.path.join(self.common_test_dir, '1 trivial')
        config = self.helper_create_native_file({
            'built-in options': {'default_library': 'static'},
            'properties': {'c_args': ['-DBAR']},
        })

        self.init(testcase, extra_args=['--native-file', config])
        configuration = self.introspect('--buildoptions')
        for each in configuration:
            if each['name'] == 'c_args':
                self.assertEqual(each['value'], ['-DBAR'])
                break
        else:
            self.fail('Did not find c_args in build options?')

    def test_builtin_options_paths(self):
        # the properties section can have lang_args, and those need to be
        # overwritten by the built-in options
        testcase = os.path.join(self.common_test_dir, '1 trivial')
        config = self.helper_create_native_file({
            'built-in options': {'bindir': 'foo'},
            'paths': {'bindir': 'bar'},
        })

        self.init(testcase, extra_args=['--native-file', config])
        configuration = self.introspect('--buildoptions')
        for each in configuration:
            if each['name'] == 'bindir':
                self.assertEqual(each['value'], 'foo')
                break
        else:
            self.fail('Did not find bindir in build options?')

    def test_builtin_options_paths_legacy(self):
        testcase = os.path.join(self.common_test_dir, '1 trivial')
        config = self.helper_create_native_file({
            'built-in options': {'default_library': 'static'},
            'paths': {'bindir': 'bar'},
        })

        self.init(testcase, extra_args=['--native-file', config])
        configuration = self.introspect('--buildoptions')
        for each in configuration:
            if each['name'] == 'bindir':
                self.assertEqual(each['value'], 'bar')
                break
        else:
            self.fail('Did not find bindir in build options?')

    @skip_if_not_language('rust')
    def test_bindgen_clang_arguments(self) -> None:
        if self.backend is not Backend.ninja:
            raise SkipTest('Rust is only supported with Ninja')

        testcase = os.path.join(self.rust_test_dir, '12 bindgen')
        config = self.helper_create_native_file({
            'properties': {'bindgen_clang_arguments': 'sentinal'}
        })

        self.init(testcase, extra_args=['--native-file', config])
        targets: T.List[T.Dict[str, T.Any]] = self.introspect('--targets')
        for t in targets:
            if t['id'].startswith('rustmod-bindgen'):
                args: T.List[str] = t['target_sources'][0]['compiler']
                self.assertIn('sentinal', args, msg="Did not find machine file value")
                cargs_start = args.index('--')
                sent_arg = args.index('sentinal')
                self.assertLess(cargs_start, sent_arg, msg='sentinal argument does not come after "--"')
                break
        else:
            self.fail('Did not find a bindgen target')


class CrossFileTests(BasePlatformTests):

    """Tests for cross file functionality not directly related to
    cross compiling.

    This is mainly aimed to testing overrides from cross files.
    """

    def setUp(self):
        super().setUp()
        self.current_config = 0
        self.current_wrapper = 0

    def _cross_file_generator(self, *, needs_exe_wrapper: bool = False,
                              exe_wrapper: T.Optional[T.List[str]] = None) -> str:
        if is_windows():
            raise SkipTest('Cannot run this test on non-mingw/non-cygwin windows')

        return textwrap.dedent(f"""\
            [binaries]
            c = '{shutil.which('gcc' if is_sunos() else 'cc')}'
            ar = '{shutil.which('ar')}'
            strip = '{shutil.which('strip')}'
            exe_wrapper = {str(exe_wrapper) if exe_wrapper is not None else '[]'}

            [properties]
            needs_exe_wrapper = {needs_exe_wrapper}

            [host_machine]
            system = 'linux'
            cpu_family = 'x86'
            cpu = 'i686'
            endian = 'little'
            """)

    def _stub_exe_wrapper(self) -> str:
        return textwrap.dedent('''\
            #!/usr/bin/env python3
            import subprocess
            import sys

            sys.exit(subprocess.run(sys.argv[1:]).returncode)
            ''')

    def test_needs_exe_wrapper_true(self):
        testdir = os.path.join(self.unit_test_dir, '70 cross test passed')
        with tempfile.TemporaryDirectory() as d:
            p = Path(d) / 'crossfile'
            with p.open('wt', encoding='utf-8') as f:
                f.write(self._cross_file_generator(needs_exe_wrapper=True))
            self.init(testdir, extra_args=['--cross-file=' + str(p)])
            out = self.run_target('test')
            self.assertRegex(out, r'Skipped:\s*1\s*\n')

    def test_needs_exe_wrapper_false(self):
        testdir = os.path.join(self.unit_test_dir, '70 cross test passed')
        with tempfile.TemporaryDirectory() as d:
            p = Path(d) / 'crossfile'
            with p.open('wt', encoding='utf-8') as f:
                f.write(self._cross_file_generator(needs_exe_wrapper=False))
            self.init(testdir, extra_args=['--cross-file=' + str(p)])
            out = self.run_target('test')
            self.assertNotRegex(out, r'Skipped:\s*1\n')

    def test_needs_exe_wrapper_true_wrapper(self):
        testdir = os.path.join(self.unit_test_dir, '70 cross test passed')
        with tempfile.TemporaryDirectory() as d:
            s = Path(d) / 'wrapper.py'
            with s.open('wt', encoding='utf-8') as f:
                f.write(self._stub_exe_wrapper())
            s.chmod(0o774)
            p = Path(d) / 'crossfile'
            with p.open('wt', encoding='utf-8') as f:
                f.write(self._cross_file_generator(
                    needs_exe_wrapper=True,
                    exe_wrapper=[str(s)]))

            self.init(testdir, extra_args=['--cross-file=' + str(p), '-Dexpect=true'])
            out = self.run_target('test')
            self.assertRegex(out, r'Ok:\s*3\s*\n')

    def test_cross_exe_passed_no_wrapper(self):
        testdir = os.path.join(self.unit_test_dir, '70 cross test passed')
        with tempfile.TemporaryDirectory() as d:
            p = Path(d) / 'crossfile'
            with p.open('wt', encoding='utf-8') as f:
                f.write(self._cross_file_generator(needs_exe_wrapper=True))

            self.init(testdir, extra_args=['--cross-file=' + str(p)])
            self.build()
            out = self.run_target('test')
            self.assertRegex(out, r'Skipped:\s*1\s*\n')

    # The test uses mocking and thus requires that the current process is the
    # one to run the Meson steps. If we are using an external test executable
    # (most commonly in Debian autopkgtests) then the mocking won't work.
    @skipIf('MESON_EXE' in os.environ, 'MESON_EXE is defined, cannot use mocking.')
    def test_cross_file_system_paths(self):
        if is_windows():
            raise SkipTest('system crossfile paths not defined for Windows (yet)')

        testdir = os.path.join(self.common_test_dir, '1 trivial')
        cross_content = self._cross_file_generator()
        with tempfile.TemporaryDirectory() as d:
            dir_ = os.path.join(d, 'meson', 'cross')
            os.makedirs(dir_)
            with tempfile.NamedTemporaryFile('w', dir=dir_, delete=False, encoding='utf-8') as f:
                f.write(cross_content)
            name = os.path.basename(f.name)

            with mock.patch.dict(os.environ, {'XDG_DATA_HOME': d}):
                self.init(testdir, extra_args=['--cross-file=' + name], inprocess=True)
                self.wipe()

            with mock.patch.dict(os.environ, {'XDG_DATA_DIRS': d}):
                os.environ.pop('XDG_DATA_HOME', None)
                self.init(testdir, extra_args=['--cross-file=' + name], inprocess=True)
                self.wipe()

        with tempfile.TemporaryDirectory() as d:
            dir_ = os.path.join(d, '.local', 'share', 'meson', 'cross')
            os.makedirs(dir_)
            with tempfile.NamedTemporaryFile('w', dir=dir_, delete=False, encoding='utf-8') as f:
                f.write(cross_content)
            name = os.path.basename(f.name)

            # If XDG_DATA_HOME is set in the environment running the
            # tests this test will fail, os mock the environment, pop
            # it, then test
            with mock.patch.dict(os.environ):
                os.environ.pop('XDG_DATA_HOME', None)
                with mock.patch('mesonbuild.coredata.os.path.expanduser', lambda x: x.replace('~', d)):
                    self.init(testdir, extra_args=['--cross-file=' + name], inprocess=True)
                    self.wipe()

    def helper_create_cross_file(self, values):
        """Create a config file as a temporary file.

        values should be a nested dictionary structure of {section: {key:
        value}}
        """
        filename = os.path.join(self.builddir, f'generated{self.current_config}.config')
        self.current_config += 1
        with open(filename, 'wt', encoding='utf-8') as f:
            for section, entries in values.items():
                f.write(f'[{section}]\n')
                for k, v in entries.items():
                    f.write(f"{k}={v!r}\n")
        return filename

    def test_cross_file_dirs(self):
        testcase = os.path.join(self.unit_test_dir, '59 native file override')
        self.init(testcase, default_args=False,
                  extra_args=['--native-file', os.path.join(testcase, 'nativefile'),
                              '--cross-file', os.path.join(testcase, 'crossfile'),
                              '-Ddef_bindir=binbar',
                              '-Ddef_datadir=databar',
                              '-Ddef_includedir=includebar',
                              '-Ddef_infodir=infobar',
                              '-Ddef_libdir=libbar',
                              '-Ddef_libexecdir=libexecbar',
                              '-Ddef_localedir=localebar',
                              '-Ddef_localstatedir=localstatebar',
                              '-Ddef_mandir=manbar',
                              '-Ddef_sbindir=sbinbar',
                              '-Ddef_sharedstatedir=sharedstatebar',
                              '-Ddef_sysconfdir=sysconfbar'])

    def test_cross_file_dirs_overridden(self):
        testcase = os.path.join(self.unit_test_dir, '59 native file override')
        self.init(testcase, default_args=False,
                  extra_args=['--native-file', os.path.join(testcase, 'nativefile'),
                              '--cross-file', os.path.join(testcase, 'crossfile'),
                              '-Ddef_libdir=liblib', '-Dlibdir=liblib',
                              '-Ddef_bindir=binbar',
                              '-Ddef_datadir=databar',
                              '-Ddef_includedir=includebar',
                              '-Ddef_infodir=infobar',
                              '-Ddef_libexecdir=libexecbar',
                              '-Ddef_localedir=localebar',
                              '-Ddef_localstatedir=localstatebar',
                              '-Ddef_mandir=manbar',
                              '-Ddef_sbindir=sbinbar',
                              '-Ddef_sharedstatedir=sharedstatebar',
                              '-Ddef_sysconfdir=sysconfbar'])

    def test_cross_file_dirs_chain(self):
        # crossfile2 overrides crossfile overrides nativefile
        testcase = os.path.join(self.unit_test_dir, '59 native file override')
        self.init(testcase, default_args=False,
                  extra_args=['--native-file', os.path.join(testcase, 'nativefile'),
                              '--cross-file', os.path.join(testcase, 'crossfile'),
                              '--cross-file', os.path.join(testcase, 'crossfile2'),
                              '-Ddef_bindir=binbar2',
                              '-Ddef_datadir=databar',
                              '-Ddef_includedir=includebar',
                              '-Ddef_infodir=infobar',
                              '-Ddef_libdir=libbar',
                              '-Ddef_libexecdir=libexecbar',
                              '-Ddef_localedir=localebar',
                              '-Ddef_localstatedir=localstatebar',
                              '-Ddef_mandir=manbar',
                              '-Ddef_sbindir=sbinbar',
                              '-Ddef_sharedstatedir=sharedstatebar',
                              '-Ddef_sysconfdir=sysconfbar'])

    def test_user_options(self):
        # This is just a touch test for cross file, since the implementation
        # shares code after loading from the files
        testcase = os.path.join(self.common_test_dir, '40 options')
        config = self.helper_create_cross_file({'project options': {'testoption': 'some other value'}})
        with self.assertRaises(subprocess.CalledProcessError) as cm:
            self.init(testcase, extra_args=['--cross-file', config])
            self.assertRegex(cm.exception.stdout, r'Incorrect value to [a-z]+ option')

    def test_builtin_options(self):
        testcase = os.path.join(self.common_test_dir, '2 cpp')
        config = self.helper_create_cross_file({'built-in options': {'cpp_std': 'c++14'}})

        self.init(testcase, extra_args=['--cross-file', config])
        configuration = self.introspect('--buildoptions')
        for each in configuration:
            if each['name'] == 'cpp_std':
                self.assertEqual(each['value'], 'c++14')
                break
        else:
            self.fail('No c++ standard set?')

    def test_builtin_options_per_machine(self):
        """Test options that are allowed to be set on a per-machine basis.

        Such options could be passed twice, once for the build machine, and
        once for the host machine. I've picked pkg-config path, but any would
        do that can be set for both.
        """
        testcase = os.path.join(self.common_test_dir, '2 cpp')
        cross = self.helper_create_cross_file({'built-in options': {'pkg_config_path': '/cross/path', 'cpp_std': 'c++17'}})
        native = self.helper_create_cross_file({'built-in options': {'pkg_config_path': '/native/path', 'cpp_std': 'c++14'}})

        # Ensure that PKG_CONFIG_PATH is not set in the environment
        with mock.patch.dict('os.environ'):
            for k in ['PKG_CONFIG_PATH', 'PKG_CONFIG_PATH_FOR_BUILD']:
                try:
                    del os.environ[k]
                except KeyError:
                    pass
            self.init(testcase, extra_args=['--cross-file', cross, '--native-file', native])

        configuration = self.introspect('--buildoptions')
        found = 0
        for each in configuration:
            if each['name'] == 'pkg_config_path':
                self.assertEqual(each['value'], ['/cross/path'])
                found += 1
            elif each['name'] == 'cpp_std':
                self.assertEqual(each['value'], 'c++17')
                found += 1
            elif each['name'] == 'build.pkg_config_path':
                self.assertEqual(each['value'], ['/native/path'])
                found += 1
            elif each['name'] == 'build.cpp_std':
                self.assertEqual(each['value'], 'c++14')
                found += 1

            if found == 4:
                break
        self.assertEqual(found, 4, 'Did not find all sections.')

    def test_builtin_options_conf_overrides_env(self):
        testcase = os.path.join(self.common_test_dir, '2 cpp')
        config = self.helper_create_cross_file({'built-in options': {'pkg_config_path': '/native', 'cpp_args': '-DFILE'}})
        cross = self.helper_create_cross_file({'built-in options': {'pkg_config_path': '/cross', 'cpp_args': '-DFILE'}})

        self.init(testcase, extra_args=['--native-file', config, '--cross-file', cross],
                  override_envvars={'PKG_CONFIG_PATH': '/bar', 'PKG_CONFIG_PATH_FOR_BUILD': '/dir',
                                    'CXXFLAGS': '-DENV', 'CXXFLAGS_FOR_BUILD': '-DENV'})
        configuration = self.introspect('--buildoptions')
        found = 0
        expected = 4
        for each in configuration:
            if each['name'] == 'pkg_config_path':
                self.assertEqual(each['value'], ['/cross'])
                found += 1
            elif each['name'] == 'build.pkg_config_path':
                self.assertEqual(each['value'], ['/native'])
                found += 1
            elif each['name'].endswith('cpp_args'):
                self.assertEqual(each['value'], ['-DFILE'])
                found += 1
            if found == expected:
                break
        self.assertEqual(found, expected, 'Did not find all sections.')

    def test_for_build_env_vars(self) -> None:
        testcase = os.path.join(self.common_test_dir, '2 cpp')
        config = self.helper_create_cross_file({'built-in options': {}})
        cross = self.helper_create_cross_file({'built-in options': {}})

        self.init(testcase, extra_args=['--native-file', config, '--cross-file', cross],
                  override_envvars={'PKG_CONFIG_PATH': '/bar', 'PKG_CONFIG_PATH_FOR_BUILD': '/dir'})
        configuration = self.introspect('--buildoptions')
        found = 0
        for each in configuration:
            if each['name'] == 'pkg_config_path':
                self.assertEqual(each['value'], ['/bar'])
                found += 1
            elif each['name'] == 'build.pkg_config_path':
                self.assertEqual(each['value'], ['/dir'])
                found += 1
            if found == 2:
                break
        self.assertEqual(found, 2, 'Did not find all sections.')

    def test_project_options_native_only(self) -> None:
        # Do not load project options from a native file when doing a cross
        # build
        testcase = os.path.join(self.unit_test_dir, '19 array option')
        config = self.helper_create_cross_file({'project options': {'list': ['bar', 'foo']}})
        cross = self.helper_create_cross_file({'binaries': {}})

        self.init(testcase, extra_args=['--native-file', config, '--cross-file', cross])
        configuration = self.introspect('--buildoptions')
        for each in configuration:
            if each['name'] == 'list':
                self.assertEqual(each['value'], ['foo', 'bar'])
                break
        else:
            self.fail('Did not find expected option.')
