# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2021 Quansight, LLC
# SPDX-FileCopyrightText: 2021 Filipe Laíns <lains@riseup.net>


"""Meson Python build backend

Implements PEP 517 hooks.
"""

from __future__ import annotations

import collections
import contextlib
import functools
import io
import itertools
import json
import os
import pathlib
import platform
import re
import shutil
import subprocess
import sys
import sysconfig
import tarfile
import tempfile
import textwrap
import typing
import warnings

from typing import (
    Any, ClassVar, DefaultDict, Dict, List, Optional, Set, TextIO, Tuple, Type,
    Union
)

import tomli

import mesonpy._compat
import mesonpy._elf
import mesonpy._tags
import mesonpy._util

from mesonpy._compat import Collection, Iterator, Mapping, Path


if typing.TYPE_CHECKING:  # pragma: no cover
    import pyproject_metadata  # noqa: F401
    import wheel.wheelfile  # noqa: F401


__version__ = '0.8.0'


class _depstr:
    """Namespace that holds the requirement strings for dependencies we *might*
    need at runtime. Having them in one place makes it easier to update.
    """
    patchelf = 'patchelf >= 0.11.0'
    wheel = 'wheel >= 0.36.0'  # noqa: F811


_COLORS = {
    'cyan': '\33[36m',
    'yellow': '\33[93m',
    'light_blue': '\33[94m',
    'bold': '\33[1m',
    'dim': '\33[2m',
    'underline': '\33[4m',
    'reset': '\33[0m',
}
_NO_COLORS = {color: '' for color in _COLORS}


def _init_colors() -> Dict[str, str]:
    """Detect if we should be using colors in the output. We will enable colors
    if running in a TTY, and no environment variable overrides it. Setting the
    NO_COLOR (https://no-color.org/) environment variable force-disables colors,
    and FORCE_COLOR forces color to be used, which is useful for thing like
    Github actions.
    """
    if 'NO_COLOR' in os.environ:
        if 'FORCE_COLOR' in os.environ:
            warnings.warn('Both NO_COLOR and FORCE_COLOR environment variables are set, disabling color')
        return _NO_COLORS
    elif 'FORCE_COLOR' in os.environ or sys.stdout.isatty():
        return _COLORS
    return _NO_COLORS


_STYLES = _init_colors()  # holds the color values, should be _COLORS or _NO_COLORS


_LINUX_NATIVE_MODULE_REGEX = re.compile(r'^(?P<name>.+)\.(?P<tag>.+)\.so$')
_WINDOWS_NATIVE_MODULE_REGEX = re.compile(r'^(?P<name>.+)\.(?P<tag>.+)\.pyd$')
_STABLE_ABI_TAG_REGEX = re.compile(r'^abi(?P<abi_number>[0-9]+)$')


def _showwarning(
    message: Union[Warning, str],
    category: Type[Warning],
    filename: str,
    lineno: int,
    file: Optional[TextIO] = None,
    line: Optional[str] = None,
) -> None:  # pragma: no cover
    """Callable to override the default warning handler, to have colored output."""
    print('{yellow}WARNING{reset} {}'.format(message, **_STYLES))


def _setup_cli() -> None:
    """Setup CLI stuff (eg. handlers, hooks, etc.). Should only be called when
    actually we are in control of the CLI, not on a normal import.
    """
    warnings.showwarning = _showwarning

    try:  # pragma: no cover
        import colorama
    except ModuleNotFoundError:  # pragma: no cover
        pass
    else:  # pragma: no cover
        colorama.init()  # fix colors on windows


class MesonBuilderError(Exception):
    """Error when building the Meson package."""


class _WheelBuilder():
    """Helper class to build wheels from projects."""

    # Maps wheel scheme names to Meson placeholder directories
    _SCHEME_MAP: ClassVar[Dict[str, Tuple[str, ...]]] = {
        'scripts': ('{bindir}',),
        'purelib': ('{py_purelib}',),
        # {moduledir_shared} should be listed in platlib here too, but currently
        # there is a Meson bug preventing us from using, so we will have to let
        # that fallback on heuristics.
        # see https://github.com/mesonbuild/meson/pull/9474
        'platlib': ('{py_platlib}',),
        'headers': ('{includedir}',),
        'data': ('{datadir}',),
        # our custom location
        'mesonpy-libs': ('{libdir}', '{libdir_shared}')
    }

    def __init__(self, project: Project) -> None:
        self._project = project
        self._libs_build_dir = project._build_dir / 'mesonpy-wheel-libs'

    @property
    def basename(self) -> str:
        """Normalized wheel name and version (eg. meson_python-1.0.0)."""
        return '{distribution}-{version}'.format(
            distribution=self._project.name.replace('-', '_'),
            version=self._project.version,
        )

    @property
    def name(self) -> str:
        """Wheel name, this includes the basename and tags."""
        return '{basename}-{python_tag}-{abi_tag}-{platform_tag}'.format(
            basename=self.basename,
            python_tag=self._project.python_tag,
            abi_tag=self._project.abi_tag,
            platform_tag=self._project.platform_tag,
        )

    @property
    def distinfo_dir(self) -> str:
        return f'{self.basename}.dist-info'

    @property
    def data_dir(self) -> str:
        return f'{self.basename}.data'

    @property
    def wheel(self) -> bytes:  # noqa: F811
        """Return WHEEL file for dist-info."""
        return textwrap.dedent('''
            Wheel-Version: 1.0
            Generator: meson
            Root-Is-Purelib: {is_purelib}
            Tag: {tags}
        ''').strip().format(
            is_purelib='true' if self._project.is_pure else 'false',
            tags=f'{self._project.python_tag}-{self._project.abi_tag}-{self._project.platform_tag}',
        ).encode()

    @property
    def _debian_python(self) -> bool:
        """Check if we are running on Debian-patched Python."""
        try:
            import distutils
            try:
                import distutils.command.install
            except ModuleNotFoundError:
                raise ModuleNotFoundError('Unable to import distutils, please install python3-distutils')
            return 'deb_system' in distutils.command.install.INSTALL_SCHEMES
        except ModuleNotFoundError:
            return False

    def _is_elf(self, file: Union[str, pathlib.Path]) -> bool:
        """Check if file is an ELF file."""
        with open(file, 'rb') as f:
            return f.read(4) == b'\x7fELF'

    def _warn_unsure_platlib(self, origin: pathlib.Path, destination: pathlib.Path) -> None:
        """Warn if we are unsure if the file should be mapped to purelib or platlib.

        This happens when we use heuristics to try to map a file purelib or
        platlib but can't differentiate between the two. In which case, we place
        the file in platlib to be safe and warn the user.

        If we can detect the file is architecture dependent and indeed does not
        belong in purelib, we will skip the warning.
        """
        # {moduledir_shared} is currently handled in heuristics due to a Meson bug,
        # but we know that files that go there are supposed to go to platlib.
        if self._is_elf(origin) or destination.root == '{moduledir_shared}':
            # The file is architecture dependent and does not belong in puredir,
            # so the warning is skipped.
            return
        warnings.warn(
            'Could not tell if file was meant for purelib or platlib, '
            f'so it was mapped to platlib: {origin} ({destination})',
            stacklevel=2,
        )

    def _map_from_heuristics(self, origin: pathlib.Path, destination: pathlib.Path) -> Optional[Tuple[str, pathlib.Path]]:
        """Extracts scheme and relative destination with heuristics based on the
        origin file and the Meson destination path.
        """
        warnings.warn('Using heuristics to map files to wheel, this may result in incorrect locations')
        sys_vars = sysconfig.get_config_vars().copy()
        sys_vars['base'] = sys_vars['platbase'] = sys.base_prefix
        sys_paths = sysconfig.get_paths(vars=sys_vars)
        # Try to map to Debian dist-packages
        if self._debian_python:
            search_path = origin
            while search_path != search_path.parent:
                search_path = search_path.parent
                if search_path.name == 'dist-packages' and search_path.parent.parent.name == 'lib':
                    calculated_path = origin.relative_to(search_path)
                    warnings.warn(f'File matched Debian heuristic ({calculated_path}): {origin} ({destination})')
                    if not destination.root == '{moduledir_shared}':
                        self._warn_unsure_platlib(origin, destination)
                    return 'platlib', calculated_path
        # Try to map to the interpreter purelib or platlib
        for scheme in ('purelib', 'platlib'):
            # try to match the install path on the system to one of the known schemes
            scheme_path = pathlib.Path(sys_paths[scheme]).absolute()
            destdir_scheme_path = self._project._install_dir / scheme_path.relative_to(scheme_path.anchor)
            try:
                wheel_path = pathlib.Path(origin).relative_to(destdir_scheme_path)
            except ValueError:
                continue
            if sys_paths['purelib'] == sys_paths['platlib']:
                self._warn_unsure_platlib(origin, destination)
            return 'platlib', wheel_path
        return None  # no match was found

    def _map_from_scheme_map(self, destination: str) -> Optional[Tuple[str, pathlib.Path]]:
        """Extracts scheme and relative destination from Meson paths.

            Meson destination path -> (wheel scheme, subpath inside the scheme)
        Eg. {bindir}/foo/bar       -> (scripts, foo/bar)
        """
        for scheme, placeholder in [
            (scheme, placeholder)
            for scheme, placeholders in self._SCHEME_MAP.items()
            for placeholder in placeholders
        ]:  # scheme name, scheme path (see self._SCHEME_MAP)
            if destination.startswith(placeholder):
                relative_destination = pathlib.Path(destination).relative_to(placeholder)
                return scheme, relative_destination
        return None  # no match was found

    def _map_to_wheel(
        self,
        sources: Dict[str, Dict[str, Any]],
        copy_files: Dict[str, str],
    ) -> DefaultDict[str, List[Tuple[pathlib.Path, str]]]:
        """Map files to the wheel, organized by scheme."""
        relative_destination: Optional[pathlib.Path]
        wheel_files = collections.defaultdict(list)
        for files in sources.values():  # entries in intro-install_plan.json
            for file, details in files.items():  # install path -> {destination, tag}
                # try mapping to wheel location
                meson_destination = details['destination']
                install_details = (
                    # using scheme map
                    self._map_from_scheme_map(meson_destination)
                    # using heuristics
                    or self._map_from_heuristics(
                        pathlib.Path(copy_files[file]),
                        pathlib.Path(meson_destination),
                    )
                )
                if install_details:
                    scheme, destination = install_details
                    wheel_files[scheme].append((destination, file))
                    continue
                # not found
                warnings.warn(
                    'File could not be mapped to an equivalent wheel directory: '
                    '{} ({})'.format(copy_files[file], meson_destination)
                )

        return wheel_files

    def _install_file(
        self,
        wheel_file: wheel.wheelfile.WheelFile,  # type: ignore[name-defined]
        counter: mesonpy._util.CLICounter,
        origin: Path,
        destination: pathlib.Path,
    ) -> None:
        """"Install" file into the wheel and do the necessary processing before
        doing so.

        Some files might need to be fixed up to set the RPATH to the internal
        library directory on Linux wheels for eg.
        """
        location = os.fspath(destination).replace(os.path.sep, '/')
        counter.update(location)

        # fix file
        if platform.system() == 'Linux':
            # add .mesonpy.libs to the RPATH of ELF files
            if self._is_elf(os.fspath(origin)):
                # copy ELF to our working directory to avoid Meson having to regenerate the file
                new_origin = self._libs_build_dir / pathlib.Path(origin).relative_to(self._project._build_dir)
                os.makedirs(new_origin.parent, exist_ok=True)
                shutil.copy2(origin, new_origin)
                origin = new_origin
                # add our in-wheel libs folder to the RPATH
                elf = mesonpy._elf.ELF(origin)
                libdir_path = f'$ORIGIN/{os.path.relpath(f".{self._project.name}.mesonpy.libs", destination.parent)}'
                if libdir_path not in elf.rpath:
                    elf.rpath = [*elf.rpath, libdir_path]

        wheel_file.write(origin, location)

    def build(
        self,
        sources: Dict[str, Dict[str, Any]],
        copy_files: Dict[str, str],
        directory: Path,
    ) -> pathlib.Path:
        import wheel.wheelfile

        self._project.build()  # ensure project is built

        wheel_file = pathlib.Path(directory, f'{self.name}.whl')
        wheel_files = self._map_to_wheel(sources, copy_files)

        with wheel.wheelfile.WheelFile(wheel_file, 'w') as whl:
            # add metadata
            whl.writestr(f'{self.distinfo_dir}/METADATA', self._project.metadata)
            whl.writestr(f'{self.distinfo_dir}/WHEEL', self.wheel)

            # add license (see https://github.com/FFY00/meson-python/issues/88)
            if self._project._metadata:
                license_ = self._project._metadata.license
                if license_ and license_.file:
                    whl.write(
                        self._project._source_dir / license_.file,
                        f'{self.distinfo_dir}/{os.path.basename(license_.file)}',
                    )

            print('{light_blue}{bold}Copying files to wheel...{reset}'.format(**_STYLES))
            with mesonpy._util.cli_counter(
                len(list(itertools.chain.from_iterable(wheel_files.values()))),
            ) as counter:
                # install root scheme files
                root_scheme = 'purelib' if self._project.is_pure else 'platlib'
                for destination, origin in wheel_files[root_scheme]:
                    self._install_file(whl, counter, origin, destination)

                # install bundled libraries
                for destination, origin in wheel_files['mesonpy-libs']:
                    assert platform.system() == 'Linux', 'Bundling libraries in wheel is currently only supported in POSIX!'
                    destination = pathlib.Path(f'.{self._project.name}.mesonpy.libs', destination)
                    self._install_file(whl, counter, origin, destination)

                # install the other schemes
                for scheme in self._SCHEME_MAP:
                    if scheme in (root_scheme, 'mesonpy-libs'):
                        continue
                    for destination, origin in wheel_files[scheme]:
                        destination = pathlib.Path(self.data_dir, scheme, destination)
                        self._install_file(whl, counter, origin, destination)

        return wheel_file


class Project():
    """Meson project wrapper to generate Python artifacts."""

    _ALLOWED_DYNAMIC_FIELDS: ClassVar[List[str]] = [
        'version',
    ]
    _metadata: Optional[pyproject_metadata.StandardMetadata]

    def __init__(
        self,
        source_dir: Path,
        working_dir: Path,
        build_dir: Optional[Path] = None,
    ) -> None:
        self._source_dir = pathlib.Path(source_dir).absolute()
        self._working_dir = pathlib.Path(working_dir).absolute()
        self._build_dir = pathlib.Path(build_dir).absolute() if build_dir else (self._working_dir / 'build')
        self._install_dir = self._working_dir / 'install'
        self._meson_native_file = self._source_dir / '.mesonpy-native-file.ini'

        # load config -- PEP 621 support is optional
        self._config = tomli.loads(self._source_dir.joinpath('pyproject.toml').read_text())
        self._pep621 = 'project' in self._config
        if self.pep621:
            try:
                import pyproject_metadata  # noqa: F811
            except ModuleNotFoundError:  # pragma: no cover
                self._metadata = None
            else:
                self._metadata = pyproject_metadata.StandardMetadata.from_pyproject(self._config, self._source_dir)
        else:
            print(
                '{yellow}{bold}! Using Meson to generate the project metadata '
                '(no `project` section in pyproject.toml){reset}'.format(**_STYLES)
            )
            self._metadata = None

        if self._metadata:
            self._validate_metadata()

        # make sure the build dir exists
        self._build_dir.mkdir(exist_ok=True)
        self._install_dir.mkdir(exist_ok=True)

        # write the native file
        native_file_data = textwrap.dedent(f'''
            [binaries]
            python3 = '{sys.executable}'
        ''')
        native_file_mismatch = (
            not self._meson_native_file.exists()
            or self._meson_native_file.read_text() != native_file_data
        )
        if native_file_mismatch:
            try:
                self._meson_native_file.write_text(native_file_data)
            except OSError:
                # if there are permission errors or something else in the source
                # directory, put the native file in the working directory instead
                # (this won't survive multiple calls -- Meson will have to be reconfigured)
                self._meson_native_file = self._working_dir / '.mesonpy-native-file.ini'
                self._meson_native_file.write_text(native_file_data)

        # configure the meson project; reconfigure if the user provided a build directory
        self._configure(reconfigure=bool(build_dir) and not native_file_mismatch)

        # set version if dynamic (this fetches it from Meson)
        if self._metadata and 'version' in self._metadata.dynamic:
            self._metadata.version = self.version

    def _proc(self, *args: str) -> None:
        """Invoke a subprocess."""
        print('{cyan}{bold}+ {}{reset}'.format(' '.join(args), **_STYLES))
        subprocess.check_call(list(args))

    def _meson(self, *args: str) -> None:
        """Invoke Meson."""
        with mesonpy._util.cd(self._build_dir):
            return self._proc('meson', *args)

    def _configure(self, reconfigure: bool = False) -> None:
        """Configure Meson project.

        We will try to reconfigure the build directory if possible to avoid
        expensive rebuilds.
        """
        setup_args = [
            f'--prefix={sys.base_prefix}',
            os.fspath(self._source_dir),
            os.fspath(self._build_dir),
        ]
        if reconfigure:
            setup_args.insert(0, '--reconfigure')

        try:
            self._meson(
                'setup',
                f'--native-file={os.fspath(self._meson_native_file)}',
                # TODO: Allow configuring these arguments
                '-Ddebug=false',
                '-Doptimization=2',
                *setup_args,
            )
        except subprocess.CalledProcessError:
            if reconfigure:  # if failed reconfiguring, try a normal configure
                self._configure()
            else:
                raise

    def _validate_metadata(self) -> None:
        """Check the pyproject.toml metadata and see if there are any issues."""

        assert self._metadata

        # check for unsupported dynamic fields
        unsupported_dynamic = {
            key for key in self._metadata.dynamic
            if key not in self._ALLOWED_DYNAMIC_FIELDS
        }
        if unsupported_dynamic:
            raise MesonBuilderError('Unsupported dynamic fields: {}'.format(
                ', '.join(unsupported_dynamic)),
            )

        # check if we are running on an unsupported interpreter
        if self._metadata.requires_python:
            self._metadata.requires_python.prereleases = True
            if platform.python_version().rstrip('+') not in self._metadata.requires_python:
                raise MesonBuilderError(
                    f'Unsupported Python version `{platform.python_version()}`, '
                    f'expected `{self._metadata.requires_python}`'
                )

    @functools.lru_cache(maxsize=None)
    def build(self) -> None:
        """Trigger the Meson build."""
        self._meson('compile')
        self._meson('install', '--destdir', os.fspath(self._install_dir))

    @classmethod
    @contextlib.contextmanager
    def with_temp_working_dir(
        cls,
        source_dir: Path = os.path.curdir,
        build_dir: Optional[Path] = None,
    ) -> Iterator[Project]:
        """Creates a project instance pointing to a temporary working directory."""
        with tempfile.TemporaryDirectory(prefix='.mesonpy-', dir=os.fspath(source_dir)) as tmpdir:
            yield cls(source_dir, tmpdir, build_dir)

    @functools.lru_cache()
    def _info(self, name: str) -> Dict[str, Any]:
        """Read info from meson-info directory."""
        file = self._build_dir.joinpath('meson-info', f'{name}.json')
        return typing.cast(
            Dict[str, str],
            json.loads(file.read_text())
        )

    @property
    def _install_plan(self) -> Dict[str, Dict[str, Dict[str, str]]]:
        """Meson install_plan metadata."""
        return self._info('intro-install_plan').copy()

    @property
    def _copy_files(self) -> Dict[str, str]:
        """Files that Meson will copy on install and the target location."""
        copy_files = {}
        for origin, destination in self._info('intro-installed').items():
            destination_path = pathlib.Path(destination).absolute()
            copy_files[origin] = os.fspath(
                self._install_dir / destination_path.relative_to(destination_path.anchor)
            )
        return copy_files

    @property
    def _lib_paths(self) -> Set[str]:
        copy_files = self._copy_files
        return {
            os.path.dirname(copy_files[file])
            for files in self._install_plan.values()
            for file, details in files.items()
            if details['destination'].startswith('{libdir_')
        }

    @property
    def _meson_name(self) -> str:
        """Name in meson.build."""
        name = self._info('intro-projectinfo')['descriptive_name']
        assert isinstance(name, str)
        return name

    @property
    def _meson_version(self) -> str:
        """Version in meson.build."""
        name = self._info('intro-projectinfo')['version']
        assert isinstance(name, str)
        return name

    @property
    def name(self) -> str:
        """Project name. Specified in pyproject.toml."""
        name = self._metadata.name if self._metadata else self._meson_name
        assert isinstance(name, str)
        return name.replace('-', '_')

    @property
    def version(self) -> str:
        """Project version. Either specified in pyproject.toml or meson.build."""
        if self._metadata and 'version' not in self._metadata.dynamic:
            version = str(self._metadata.version)
        else:
            version = self._meson_version
        assert isinstance(version, str)
        return version

    @property  # type: ignore[misc]
    @functools.lru_cache(maxsize=1)
    def metadata(self) -> bytes:  # noqa: C901
        """Project metadata."""
        # the rest of the keys are only available when using PEP 621 metadata
        if not self.pep621:
            return textwrap.dedent(f'''
                Metadata-Version: 2.1
                Name: {self.name}
                Version: {self.version}
            ''').strip().encode()
        # re-import pyproject_metadata to raise ModuleNotFoundError if it is really missing
        import pyproject_metadata  # noqa: F401, F811
        assert self._metadata
        # use self.version as the version may be dynamic -- fetched from Meson
        core_metadata = self._metadata.as_rfc822()
        core_metadata.headers['Version'] = [self.version]
        return bytes(core_metadata)

    @property
    def is_pure(self) -> bool:
        """Is the wheel "pure" (architecture independent)?"""
        # XXX: I imagine some users might want to force the package to be
        # non-pure, but I think it's better that we evaluate use-cases as they
        # arise and make sure allowing the user to override this is indeed the
        # best option for the use-case.
        not_pure = ('{bindir}', '{libdir_shared}', '{libdir_static}', '{py_platlib}', '{moduledir_shared}')
        for data_type, files in self._install_plan.items():
            for entry in files.values():
                if entry['destination'] is None:  # pragma: no cover
                    continue
                if any(key in entry['destination'] for key in not_pure):
                    return False
        return True

    @property
    def pep621(self) -> bool:
        """Does the project use PEP 621 metadata?"""
        return self._pep621

    @property
    def python_tag(self) -> str:
        selected_tag = self._select_abi_tag()
        if selected_tag and selected_tag.python:
            return selected_tag.python
        return 'py3'

    @property
    def abi_tag(self) -> str:
        selected_tag = self._select_abi_tag()
        if selected_tag:
            return selected_tag.abi
        return 'none'

    @property
    def platform_tag(self) -> str:
        if self.is_pure:
            return 'any'
        # XXX: Choose the sysconfig platform here and let something like auditwheel
        #      fix it later if there are system dependencies (eg. replace it with a manylinux tag)
        platform_ = sysconfig.get_platform()
        parts = platform_.split('-')
        if parts[0] == 'macosx' and parts[1] in ('11', '12'):
            # Workaround for bug where pypa/packaging does not consider macOS
            # tags without minor versions valid. Some Python flavors (Homebrew
            # for example) on macOS started to do this in version 11, and
            # pypa/packaging should handle things correctly from version 13 and
            # forward, so we will add a 0 minor version to MacOS 11 and 12.
            # https://github.com/FFY00/meson-python/issues/91
            # https://github.com/pypa/packaging/issues/578
            parts[1] += '.0'
            platform_ = '-'.join(parts)
        return platform_.replace('-', '_').replace('.', '_')

    def _calculate_file_abi_tag_heuristic_windows(self, filename: str) -> Optional[mesonpy._tags.Tag]:
        """Try to calculate the Windows tag from the Python extension file name."""
        match = _WINDOWS_NATIVE_MODULE_REGEX.match(filename)
        if not match:
            return None
        tag = match.group('tag')

        try:
            return mesonpy._tags.StableABITag(tag)
        except ValueError:
            return mesonpy._tags.WindowsInterpreterTag(tag)

    def _calculate_file_abi_tag_heuristic_posix(self, filename: str) -> Optional[mesonpy._tags.Tag]:
        """Try to calculate the Posix tag from the Python extension file name."""
        # sysconfig is not guaranted to export SHLIB_SUFFIX but let's be
        # preventive and check its value to make sure it matches our expectations
        try:
            extension = sysconfig.get_config_vars().get('SHLIB_SUFFIX', '.so')
            if extension != '.so':
                raise NotImplementedError(
                    f"We don't currently support the {extension} extension. "
                    'Please report this to https://github.com/FFY00/mesonpy/issues '
                    'and include information about your operating system.'
                )
        except KeyError:
            warnings.warn(
                'sysconfig does not export SHLIB_SUFFIX, so we are unable to '
                'perform the sanity check regarding the extension suffix. '
                'Please report this to https://github.com/FFY00/mesonpy/issues '
                'and include the output of `python -m sysconfig`.'
            )
        match = _LINUX_NATIVE_MODULE_REGEX.match(filename)
        if not match:  # this file does not appear to be a native module
            return None
        tag = match.group('tag')

        try:
            return mesonpy._tags.StableABITag(tag)
        except ValueError:
            return mesonpy._tags.LinuxInterpreterTag(tag)

    def _calculate_file_abi_tag_heuristic(self, filename: str) -> Optional[mesonpy._tags.Tag]:
        """Try to calculate the ABI tag from the Python extension file name."""
        if os.name == 'nt':
            return self._calculate_file_abi_tag_heuristic_windows(filename)
        # everything else *should* follow the POSIX way, at least to my knowledge
        return self._calculate_file_abi_tag_heuristic_posix(filename)

    def _file_list_repr(self, files: Collection[str], prefix: str = '\t\t', max_count: int = 3) -> str:
        if len(files) > max_count:
            files = list(itertools.islice(files, max_count)) + [f'(... +{len(files)}))']
        return ''.join(f'{prefix}- {file}\n' for file in files)

    def _files_by_tag(self) -> Mapping[mesonpy._tags.Tag, Collection[str]]:
        """Map files into ABI tags."""
        files_by_tag: Dict[mesonpy._tags.Tag, List[str]] = collections.defaultdict(list)
        for file, details in self._install_plan.get('targets', {}).items():
            destination = pathlib.Path(details['destination'])

            # if in platlib, calculate the ABI tag
            if not (
                mesonpy._compat.is_relative_to(destination, '{py_platlib}')
                or mesonpy._compat.is_relative_to(destination, '{moduledir_shared}')
            ):
                # XXX: Ideally we would just check the anchor placeholder in the
                #      path (eg. check if it is {py_platlib}) but Meson seems to
                #      have a bug where it sometimes returns a full path without
                #      placeholder, so we will check if the file path is
                #      relative to platlib.
                #      This can be problematic because the platlib path might be
                #      same one used for other schemes. In these situations we
                #      will be picking up files are not supposed to be in
                #      platlib, and that could just be supporting files.
                #      See https://github.com/FFY00/meson-python/issues/95
                #      Meson bug: https://github.com/mesonbuild/meson/issues/10601
                from_heuristic = False
                sys_vars = sysconfig.get_config_vars().copy()
                sys_vars['base'] = sys_vars['platbase'] = sys.base_prefix
                platlib = sysconfig.get_path('platlib', vars=sys_vars)
                if platlib and mesonpy._compat.is_relative_to(destination, platlib):
                    from_heuristic = True
                else:
                    continue

            tag = self._calculate_file_abi_tag_heuristic(file)
            if tag:
                if from_heuristic:
                    warnings.warn(
                        'Could not tell with certainty if this file was meant '
                        'to be mapped to platlib, but it was used to calculate '
                        f'the ABI tag: {destination}'
                    )
                files_by_tag[tag].append(file)

        return files_by_tag

    def _select_abi_tag(self) -> Optional[mesonpy._tags.Tag]:  # noqa: C901
        """Given a list of ABI tags, selects the most specific one.

        Raises an error if there are incompatible tags.
        """
        # Possibilities:
        #   - interpreter specific (cpython/pypy/etc, version)
        #   - stable abi (abiX)
        tags = self._files_by_tag()
        selected_tag = None
        for tag, files in tags.items():
            if __debug__:  # sanity check
                if os.name == 'nt':
                    assert not isinstance(tag, mesonpy._tags.LinuxInterpreterTag)
                else:
                    assert not isinstance(tag, mesonpy._tags.WindowsInterpreterTag)
            # no selected tag yet, let's assign this one
            if not selected_tag:
                selected_tag = tag
            # interpreter tags
            elif isinstance(tag, mesonpy._tags.LinuxInterpreterTag):
                if tag != selected_tag:
                    if isinstance(selected_tag, mesonpy._tags.LinuxInterpreterTag):
                        raise ValueError(
                            'Found files with incompatible ABI tags:\n'
                            + self._file_list_repr(tags[selected_tag])
                            + '\tand\n'
                            + self._file_list_repr(files)
                        )
                    selected_tag = tag
            elif isinstance(tag, mesonpy._tags.WindowsInterpreterTag):
                if tag != selected_tag:
                    if isinstance(selected_tag, mesonpy._tags.WindowsInterpreterTag):
                        warnings.warn(
                            'Found files with different ABI tags but couldn\'t tell '
                            'if they are incompatible:\n'
                            + self._file_list_repr(tags[selected_tag])
                            + '\tand\n'
                            + self._file_list_repr(files)
                            + 'Please report this to https://github.com/FFY00/mesonpy/issues.'
                        )
                    selected_tag = tag
            # stable ABI
            elif isinstance(tag, mesonpy._tags.StableABITag):
                if isinstance(selected_tag, mesonpy._tags.StableABITag) and tag != selected_tag:
                    raise ValueError(
                        'Found files with incompatible ABI tags:\n'
                        + self._file_list_repr(tags[selected_tag])
                        + '\tand\n'
                        + self._file_list_repr(files)
                    )
        return selected_tag

    def sdist(self, directory: Path) -> pathlib.Path:
        """Generates a sdist (source distribution) in the specified directory."""
        # generate meson dist file
        self._meson('dist', '--allow-dirty', '--no-tests', '--formats', 'gztar')

        # move meson dist file to output path
        dist_name = f'{self.name}-{self.version}'
        meson_dist_name = f'{self._meson_name}-{self._meson_version}'
        meson_dist_path = pathlib.Path(self._build_dir, 'meson-dist', f'{meson_dist_name}.tar.gz')
        sdist = pathlib.Path(directory, f'{dist_name}.tar.gz')

        with tarfile.open(meson_dist_path, 'r:gz') as meson_dist, mesonpy._util.create_targz(sdist) as (tar, mtime):
            for member in meson_dist.getmembers():
                # skip the generated meson native file
                if member.name == f'{meson_dist_name}/.mesonpy-native-file.ini':
                    continue

                # calculate the file path in the source directory
                assert member.name, member.name
                member_parts = member.name.split('/')
                if len(member_parts) <= 1:
                    continue
                path = self._source_dir.joinpath(*member_parts[1:])

                if not path.is_file():
                    continue

                info = tarfile.TarInfo(member.name)
                info.size = os.path.getsize(path)

                # rewrite the path if necessary, to match the sdist distribution name
                if dist_name != meson_dist_name:
                    info.name = pathlib.Path(
                        dist_name,
                        path.relative_to(self._source_dir)
                    ).as_posix()

                with path.open('rb') as f:
                    tar.addfile(info, fileobj=f)

            # add PKG-INFO to dist file to make it a sdist
            pkginfo_info = tarfile.TarInfo(f'{dist_name}/PKG-INFO')
            if mtime:
                pkginfo_info.mtime = mtime
            pkginfo_info.size = len(self.metadata)  # type: ignore[arg-type]
            tar.addfile(pkginfo_info, fileobj=io.BytesIO(self.metadata))  # type: ignore[arg-type]

        return sdist

    def wheel(self, directory: Path) -> pathlib.Path:  # noqa: F811
        """Generates a wheel (binary distribution) in the specified directory."""
        wheel = _WheelBuilder(self).build(self._install_plan, self._copy_files, self._build_dir)

        final_wheel = pathlib.Path(directory, wheel.name)
        shutil.move(os.fspath(wheel), final_wheel)
        return final_wheel


@contextlib.contextmanager
def _project(config_settings: Optional[Dict[Any, Any]]) -> Iterator[Project]:
    """Create the project given the given config settings."""
    if config_settings is None:
        config_settings = {}

    with Project.with_temp_working_dir(
        build_dir=config_settings.get('builddir'),
    ) as project:
        yield project


def build_sdist(
    sdist_directory: str,
    config_settings: Optional[Dict[Any, Any]] = None,
) -> str:
    _setup_cli()

    out = pathlib.Path(sdist_directory)
    with _project(config_settings) as project:
        return project.sdist(out).name


def get_requires_for_build_wheel(
    config_settings: Optional[Dict[str, str]] = None,
) -> List[str]:
    dependencies = [_depstr.wheel]
    with _project(config_settings) as project:
        if not project.is_pure and platform.system() == 'Linux':
            # we may need patchelf
            if not shutil.which('patchelf'):  # XXX: This is slightly dangerous.
                # patchelf not already acessible on the system
                dependencies.append(_depstr.patchelf)
    return dependencies


def build_wheel(
    wheel_directory: str,
    config_settings: Optional[Dict[Any, Any]] = None,
    metadata_directory: Optional[str] = None,
) -> str:
    _setup_cli()

    out = pathlib.Path(wheel_directory)
    with _project(config_settings) as project:
        return project.wheel(out).name
