# SPDX-License-Identifier: Apache-2.0
# Copyright 2016-2021 The Meson development team
# Copyright © 2024 Intel Corporation

from __future__ import annotations
from pathlib import PurePath
from unittest import mock, TestCase, SkipTest
import json
import io
import os
import re
import subprocess
import sys
import shutil
import tempfile
import typing as T

import mesonbuild.mlog
import mesonbuild.depfile
import mesonbuild.dependencies.base
import mesonbuild.dependencies.factory
import mesonbuild.compilers
import mesonbuild.envconfig
import mesonbuild.environment
import mesonbuild.coredata
import mesonbuild.modules.gnome
from mesonbuild.mesonlib import (
    is_windows, is_cygwin, join_args, split_args, windows_proof_rmtree, python_command
)
import mesonbuild.modules.pkgconfig


from run_tests import (
    Backend, get_backend_commands,
    get_builddir_target_args, get_meson_script, run_configure_inprocess,
    run_mtest_inprocess, handle_meson_skip_test,
)

if T.TYPE_CHECKING:
    from typing_extensions import TypeAlias, TypedDict

    class CompDbEntry(TypedDict):

        # `output` is not strictly required, but Ninja always generates it
        # `arguments` is allowed, but Ninja never generates it

        directory: str
        command: str
        file: str
        output: str

    CompDB: TypeAlias = T.List[CompDbEntry]


# magic attribute used by unittest.result.TestResult._is_relevant_tb_level
# This causes tracebacks to hide these internal implementation details,
# e.g. for assertXXX helpers.
__unittest = True

class BasePlatformTests(TestCase):
    prefix = '/usr'
    libdir = 'lib'
    _mktmpdir: T.Callable[[], str]

    @classmethod
    def setUpClass(cls) -> None:
        super().setUpClass()
        cls.maxDiff = None
        src_root = str(PurePath(__file__).parents[1])
        cls.src_root = src_root
        # Get the backend
        cls.backend_name = os.environ.get('MESON_UNIT_TEST_BACKEND', 'ninja')
        backend_type = 'vs' if cls.backend_name.startswith('vs') else cls.backend_name
        cls.backend = getattr(Backend, backend_type)
        cls.meson_args = ['--backend=' + cls.backend_name]
        cls.meson_command = python_command + [get_meson_script()]
        cls.setup_command = cls.meson_command + ['setup'] + cls.meson_args
        cls.mconf_command = cls.meson_command + ['configure']
        cls.mintro_command = cls.meson_command + ['introspect']
        cls.wrap_command = cls.meson_command + ['wrap']
        cls.rewrite_command = cls.meson_command + ['rewrite']
        # Backend-specific build commands
        cls.build_command, cls.clean_command, cls.test_command, cls.install_command, \
            cls.uninstall_command = get_backend_commands(cls.backend)
        # Test directories
        cls.common_test_dir = os.path.join(src_root, 'test cases/common')
        cls.python_test_dir = os.path.join(src_root, 'test cases/python')
        cls.rust_test_dir = os.path.join(src_root, 'test cases/rust')
        cls.vala_test_dir = os.path.join(src_root, 'test cases/vala')
        cls.framework_test_dir = os.path.join(src_root, 'test cases/frameworks')
        cls.unit_test_dir = os.path.join(src_root, 'test cases/unit')
        cls.rewrite_test_dir = os.path.join(src_root, 'test cases/rewrite')
        cls.linuxlike_test_dir = os.path.join(src_root, 'test cases/linuxlike')
        cls.objc_test_dir = os.path.join(src_root, 'test cases/objc')
        cls.objcpp_test_dir = os.path.join(src_root, 'test cases/objcpp')
        cls.darwin_test_dir = os.path.join(src_root, 'test cases/darwin')
        cls.fortran_test_dir = os.path.join(src_root, 'test cases/fortran')

        # Misc stuff
        if cls.backend is Backend.ninja:
            cls.no_rebuild_stdout = ['ninja: no work to do.', 'samu: nothing to do']
        else:
            # VS doesn't have a stable output when no changes are done
            # XCode backend is untested with unit tests, help welcome!
            cls.no_rebuild_stdout = [f'UNKNOWN BACKEND {cls.backend.name!r}']

        cls.env_patch = mock.patch.dict(os.environ)
        cls.env_patch.start()

        os.environ['COLUMNS'] = '80'
        os.environ['PYTHONIOENCODING'] = 'utf8'

        # If set to 1, force unittests to put their working stuff in /tmp
        # Many Windows virus scanners have issues with the files being in the
        # tmp directory.
        #
        # Store as an attribute because this path is very hot.
        # the use of `staticmethod()` here is required because of Python's
        # Object model, which otherwise sees these as methods
        if (is_windows() or is_cygwin()) and not os.environ.get('MESON_FORCE_UNITTEST_IN_TMP', '0') == '1':
            def tmpdir() -> str:
                return tempfile.mkdtemp(dir=os.getcwd())

            cls._mktmpdir = staticmethod(tmpdir)
        else:
            cls._mktmpdir = staticmethod(tempfile.mkdtemp)

    @classmethod
    def tearDownClass(cls) -> None:
        super().tearDownClass()
        cls.env_patch.stop()

    def setUp(self):
        super().setUp()
        self.meson_native_files = []
        self.meson_cross_files = []
        self.new_builddir()

    def change_builddir(self, newdir: str) -> None:
        self.builddir = newdir
        self.privatedir = os.path.join(self.builddir, 'meson-private')
        self.logdir = os.path.join(self.builddir, 'meson-logs')
        self.installdir = os.path.join(self.builddir, 'install')
        self.distdir = os.path.join(self.builddir, 'meson-dist')
        self.mtest_command = self.meson_command + ['test', '-C', self.builddir]
        if os.path.islink(newdir):
            self.addCleanup(os.unlink, self.builddir)
        else:
            self.addCleanup(windows_proof_rmtree, self.builddir)

    def new_builddir(self) -> None:
        newdir = self._mktmpdir()
        # In case the directory is inside a symlinked directory, find the real
        # path otherwise we might not find the srcdir from inside the builddir.
        newdir = os.path.realpath(newdir)
        self.change_builddir(newdir)

    def _open_meson_log(self) -> io.TextIOWrapper:
        log = os.path.join(self.logdir, 'meson-log.txt')
        return open(log, encoding='utf-8')

    def _get_meson_log(self) -> T.Optional[str]:
        try:
            with self._open_meson_log() as f:
                return f.read()
        except FileNotFoundError as e:
            print(f"{e.filename!r} doesn't exist", file=sys.stderr)
            return None

    def _print_meson_log(self) -> None:
        log = self._get_meson_log()
        if log:
            print(log)

    def _run(self, command, *, workdir=None, override_envvars: T.Optional[T.Mapping[str, str]] = None, stderr=True):
        '''
        Run a command while printing the stdout and stderr to stdout,
        and also return a copy of it
        '''
        # If this call hangs CI will just abort. It is very hard to distinguish
        # between CI issue and test bug in that case. Set timeout and fail loud
        # instead.
        if override_envvars is None:
            env = None
        else:
            env = os.environ.copy()
            env.update(override_envvars)

        proc = subprocess.run(command, stdout=subprocess.PIPE,
                              stderr=subprocess.STDOUT if stderr else subprocess.PIPE,
                              env=env,
                              encoding='utf-8',
                              text=True, cwd=workdir, timeout=60 * 5)
        print('$', join_args(command))
        print('stdout:')
        print(proc.stdout)
        if not stderr:
            print('stderr:')
            print(proc.stderr)
        if proc.returncode != 0:
            skipped, reason = handle_meson_skip_test(proc.stdout)
            if skipped:
                raise SkipTest(f'Project requested skipping: {reason}')
            raise subprocess.CalledProcessError(proc.returncode, command, output=proc.stdout)
        return proc.stdout

    def init(self, srcdir, *,
             extra_args=None,
             default_args=True,
             inprocess=False,
             override_envvars: T.Optional[T.Mapping[str, str]] = None,
             workdir=None,
             allow_fail: bool = False) -> str:
        """Call `meson setup`

        :param allow_fail: If set to true initialization is allowed to fail.
            When it does the log will be returned instead of stdout.
        :return: the value of stdout on success, or the meson log on failure
            when :param allow_fail: is true
        """
        self.assertPathExists(srcdir)
        if extra_args is None:
            extra_args = []
        if not isinstance(extra_args, list):
            extra_args = [extra_args]
        build_and_src_dir_args = [self.builddir, srcdir]
        args = []
        if default_args:
            args += ['--prefix', self.prefix]
            if self.libdir:
                args += ['--libdir', self.libdir]
            for f in self.meson_native_files:
                args += ['--native-file', f]
            for f in self.meson_cross_files:
                args += ['--cross-file', f]
        self.privatedir = os.path.join(self.builddir, 'meson-private')
        if inprocess:
            try:
                returncode, out, err = run_configure_inprocess(['setup'] + self.meson_args + args + extra_args + build_and_src_dir_args, override_envvars)
            except Exception as e:
                if not allow_fail:
                    self._print_meson_log()
                    raise
                out = self._get_meson_log()  # Best we can do here
                err = ''  # type checkers can't figure out that on this path returncode will always be 0
                returncode = 0
            finally:
                # Close log file to satisfy Windows file locking
                mesonbuild.mlog.shutdown()
                mesonbuild.mlog._logger.log_dir = None
                mesonbuild.mlog._logger.log_file = None

            skipped, reason = handle_meson_skip_test(out)
            if skipped:
                raise SkipTest(f'Project requested skipping: {reason}')
            if returncode != 0:
                self._print_meson_log()
                print('Stdout:\n')
                print(out)
                print('Stderr:\n')
                print(err)
                if not allow_fail:
                    raise RuntimeError('Configure failed')
        else:
            try:
                out = self._run(self.setup_command + args + extra_args + build_and_src_dir_args, override_envvars=override_envvars, workdir=workdir)
            except Exception:
                if not allow_fail:
                    self._print_meson_log()
                    raise
                out = self._get_meson_log()  # best we can do here
        return out

    def build(self, target=None, *, extra_args=None, override_envvars=None, stderr=True):
        if extra_args is None:
            extra_args = []
        # Add arguments for building the target (if specified),
        # and using the build dir (if required, with VS)
        args = get_builddir_target_args(self.backend, self.builddir, target)
        return self._run(self.build_command + args + extra_args, workdir=self.builddir, override_envvars=override_envvars, stderr=stderr)

    def clean(self, *, override_envvars=None):
        dir_args = get_builddir_target_args(self.backend, self.builddir, None)
        self._run(self.clean_command + dir_args, workdir=self.builddir, override_envvars=override_envvars)

    def run_tests(self, *, inprocess=False, override_envvars=None):
        if not inprocess:
            return self._run(self.test_command, workdir=self.builddir, override_envvars=override_envvars)
        else:
            with mock.patch.dict(os.environ, override_envvars):
                return run_mtest_inprocess(['-C', self.builddir])[1]

    def install(self, *, use_destdir=True, override_envvars=None):
        if self.backend is not Backend.ninja:
            raise SkipTest(f'{self.backend.name!r} backend can\'t install files')
        if use_destdir:
            destdir = {'DESTDIR': self.installdir}
            if override_envvars is None:
                override_envvars = destdir
            else:
                override_envvars.update(destdir)
        return self._run(self.install_command, workdir=self.builddir, override_envvars=override_envvars)

    def uninstall(self, *, override_envvars=None):
        self._run(self.uninstall_command, workdir=self.builddir, override_envvars=override_envvars)

    def run_target(self, target, *, override_envvars=None):
        '''
        Run a Ninja target while printing the stdout and stderr to stdout,
        and also return a copy of it
        '''
        return self.build(target=target, override_envvars=override_envvars)

    def setconf(self, arg: T.Sequence[str], will_build: bool = True) -> None:
        if isinstance(arg, str):
            arg = [arg]
        else:
            arg = list(arg)
        self._run(self.mconf_command + arg + [self.builddir])
        if will_build:
            self.build()

    def getconf(self, optname: str, opts=None):
        if opts is None:
            opts = self.introspect('--buildoptions')
        for x in opts:
            if x.get('name') == optname:
                return x.get('value')
        self.fail(f'Option {optname} not found')

    def wipe(self):
        windows_proof_rmtree(self.builddir)

    def utime(self, f):
        os.utime(f)

    def get_compdb(self) -> CompDB:
        if self.backend is not Backend.ninja:
            raise SkipTest(f'Compiler db not available with {self.backend.name} backend')
        try:
            with open(os.path.join(self.builddir, 'compile_commands.json'), encoding='utf-8') as ifile:
                contents = T.cast('CompDB', json.load(ifile))
        except FileNotFoundError:
            raise SkipTest('Compiler db not found')
        # If Ninja is using .rsp files, generate them, read their contents, and
        # replace it as the command for all compile commands in the parsed json.
        if contents and contents[0]['command'].endswith('.rsp'):
            # Pretend to build so that the rsp files are generated
            self.build(extra_args=['-d', 'keeprsp', '-n'])
            for each in contents:
                # Extract the actual command from the rsp file
                compiler, rsp = each['command'].split(' @')
                rsp = os.path.join(self.builddir, rsp)
                # Replace the command with its contents
                with open(rsp, encoding='utf-8') as f:
                    each['command'] = compiler + ' ' + f.read()
        return contents

    def get_meson_log_raw(self):
        with self._open_meson_log() as f:
            return f.read()

    def get_meson_log(self):
        with self._open_meson_log() as f:
            return f.readlines()

    def get_meson_log_compiler_checks(self):
        '''
        Fetch a list command-lines run by meson for compiler checks.
        Each command-line is returned as a list of arguments.
        '''
        prefix = 'Command line: `'
        suffix = '` -> 0\n'
        with self._open_meson_log() as log:
            cmds = [split_args(l[len(prefix):-len(suffix)]) for l in log if l.startswith(prefix)]
            return cmds

    def get_meson_log_sanitychecks(self):
        '''
        Same as above, but for the sanity checks that were run
        '''
        prefix = 'Sanity check compiler command line:'
        with self._open_meson_log() as log:
            cmds = [l[len(prefix):].split() for l in log if l.startswith(prefix)]
            return cmds

    def introspect(self, args):
        if isinstance(args, str):
            args = [args]
        out = subprocess.check_output(self.mintro_command + args + [self.builddir],
                                      encoding='utf-8', universal_newlines=True)
        return json.loads(out)

    def introspect_directory(self, directory, args):
        if isinstance(args, str):
            args = [args]
        out = subprocess.check_output(self.mintro_command + args + [directory],
                                      encoding='utf-8', universal_newlines=True)
        try:
            obj = json.loads(out)
        except Exception as e:
            print(out)
            raise e
        return obj

    def assertPathEqual(self, path1, path2):
        '''
        Handles a lot of platform-specific quirks related to paths such as
        separator, case-sensitivity, etc.
        '''
        self.assertEqual(PurePath(path1), PurePath(path2))

    def assertPathListEqual(self, pathlist1, pathlist2):
        self.assertEqual(len(pathlist1), len(pathlist2))
        worklist = list(zip(pathlist1, pathlist2))
        for i in worklist:
            if i[0] is None:
                self.assertEqual(i[0], i[1])
            else:
                self.assertPathEqual(i[0], i[1])

    def assertPathBasenameEqual(self, path, basename):
        msg = f'{path!r} does not end with {basename!r}'
        # We cannot use os.path.basename because it returns '' when the path
        # ends with '/' for some silly reason. This is not how the UNIX utility
        # `basename` works.
        path_basename = PurePath(path).parts[-1]
        self.assertEqual(PurePath(path_basename), PurePath(basename), msg)

    def assertReconfiguredBuildIsNoop(self):
        'Assert that we reconfigured and then there was nothing to do'
        ret = self.build(stderr=False)
        self.assertIn('The Meson build system', ret)
        if self.backend is Backend.ninja:
            for line in ret.split('\n'):
                if line in self.no_rebuild_stdout:
                    break
            else:
                raise AssertionError('build was reconfigured, but was not no-op')
        elif self.backend is Backend.vs:
            # Ensure that some target said that no rebuild was done
            # XXX: Note CustomBuild did indeed rebuild, because of the regen checker!
            self.assertIn('ClCompile:\n  All outputs are up-to-date.', ret)
            self.assertIn('Link:\n  All outputs are up-to-date.', ret)
            # Ensure that no targets were built
            self.assertNotRegex(ret, re.compile('ClCompile:\n [^\n]*cl', flags=re.IGNORECASE))
            self.assertNotRegex(ret, re.compile('Link:\n [^\n]*link', flags=re.IGNORECASE))
        elif self.backend is Backend.xcode:
            raise SkipTest('Please help us fix this test on the xcode backend')
        else:
            raise RuntimeError(f'Invalid backend: {self.backend.name!r}')

    def assertBuildIsNoop(self):
        ret = self.build(stderr=False)
        if self.backend is Backend.ninja:
            self.assertIn(ret.split('\n')[-2], self.no_rebuild_stdout)
        elif self.backend is Backend.vs:
            # Ensure that some target of each type said that no rebuild was done
            # We always have at least one CustomBuild target for the regen checker
            self.assertIn('CustomBuild:\n  All outputs are up-to-date.', ret)
            self.assertIn('ClCompile:\n  All outputs are up-to-date.', ret)
            self.assertIn('Link:\n  All outputs are up-to-date.', ret)
            # Ensure that no targets were built
            self.assertNotRegex(ret, re.compile('CustomBuild:\n [^\n]*cl', flags=re.IGNORECASE))
            self.assertNotRegex(ret, re.compile('ClCompile:\n [^\n]*cl', flags=re.IGNORECASE))
            self.assertNotRegex(ret, re.compile('Link:\n [^\n]*link', flags=re.IGNORECASE))
        elif self.backend is Backend.xcode:
            raise SkipTest('Please help us fix this test on the xcode backend')
        else:
            raise RuntimeError(f'Invalid backend: {self.backend.name!r}')

    def assertRebuiltTarget(self, target):
        ret = self.build()
        if self.backend is Backend.ninja:
            self.assertIn(f'Linking target {target}', ret)
        elif self.backend is Backend.vs:
            # Ensure that this target was rebuilt
            linkre = re.compile('Link:\n [^\n]*link[^\n]*' + target, flags=re.IGNORECASE)
            self.assertRegex(ret, linkre)
        elif self.backend is Backend.xcode:
            raise SkipTest('Please help us fix this test on the xcode backend')
        else:
            raise RuntimeError(f'Invalid backend: {self.backend.name!r}')

    @staticmethod
    def get_target_from_filename(filename):
        base = os.path.splitext(filename)[0]
        if base.startswith(('lib', 'cyg')):
            return base[3:]
        return base

    def assertBuildRelinkedOnlyTarget(self, target):
        ret = self.build()
        if self.backend is Backend.ninja:
            linked_targets = []
            for line in ret.split('\n'):
                if 'Linking target' in line:
                    fname = line.rsplit('target ')[-1]
                    linked_targets.append(self.get_target_from_filename(fname))
            self.assertEqual(linked_targets, [target])
        elif self.backend is Backend.vs:
            # Ensure that this target was rebuilt
            linkre = re.compile(r'Link:\n  [^\n]*link.exe[^\n]*/OUT:".\\([^"]*)"', flags=re.IGNORECASE)
            matches = linkre.findall(ret)
            self.assertEqual(len(matches), 1, msg=matches)
            self.assertEqual(self.get_target_from_filename(matches[0]), target)
        elif self.backend is Backend.xcode:
            raise SkipTest('Please help us fix this test on the xcode backend')
        else:
            raise RuntimeError(f'Invalid backend: {self.backend.name!r}')

    def assertPathExists(self, path):
        m = f'Path {path!r} should exist'
        self.assertTrue(os.path.exists(path), msg=m)

    def assertPathDoesNotExist(self, path):
        m = f'Path {path!r} should not exist'
        self.assertFalse(os.path.exists(path), msg=m)

    def assertLength(self, val, length):
        assert len(val) == length, f'{val} is not length {length}'

    def copy_srcdir(self, srcdir: str) -> str:
        """Copies a source tree and returns that copy.

        ensures that the copied tree is deleted after running.

        :param srcdir: The location of the source tree to copy
        :return: The location of the copy
        """
        dest = tempfile.mkdtemp()
        self.addCleanup(windows_proof_rmtree, dest)

        # shutil.copytree expects the destination directory to not exist, Once
        # python 3.8 is required the `dirs_exist_ok` parameter negates the need
        # for this
        dest = os.path.join(dest, 'subdir')

        shutil.copytree(srcdir, dest)

        return dest
